From ba73b8bdf7fdac25569f9f604371a70e6b4b642f Mon Sep 17 00:00:00 2001 From: retoor Date: Tue, 11 Nov 2025 01:05:13 +0100 Subject: [PATCH] Update. --- rbox/billing/models.py | 12 +- rbox/billing/stripe_client.py | 18 +- rbox/billing/usage_tracker.py | 13 +- rbox/routers/auth.py | 13 +- rbox/routers/billing.py | 288 +++++++++++++--------- static/css/billing.css | 30 +++ static/css/code-editor-view.css | 30 ++- static/index.html | 1 + static/js/api-contract.js | 42 ++++ static/js/api.js | 193 +++++++++++---- static/js/app.js | 111 +++++++++ static/js/components/admin-billing.js | 17 ++ static/js/components/base-component.js | 78 ++++++ static/js/components/billing-dashboard.js | 99 +++++++- static/js/components/code-editor-view.js | 210 ++++++++++------ static/js/components/error-boundary.js | 101 ++++++++ static/js/components/file-preview.js | 8 - static/js/components/login-view.js | 11 +- static/js/components/rbox-app.js | 152 +++++++++--- static/js/fetch-interceptor.js | 47 ++++ static/js/focus-manager.js | 83 +++++++ static/js/form-validator.js | 61 +++++ static/js/lazy-loader.js | 68 +++++ static/js/logger.js | 99 ++++++++ static/js/main.js | 66 ++++- static/js/perf-monitor.js | 71 ++++++ static/js/router.js | 125 ++++++++++ static/js/startup-check.js | 89 +++++++ static/js/state.js | 92 +++++++ static/js/utils.js | 71 ++++++ tests/e2e/conftest.py | 33 ++- 31 files changed, 2026 insertions(+), 306 deletions(-) create mode 100644 static/js/api-contract.js create mode 100644 static/js/app.js create mode 100644 static/js/components/base-component.js create mode 100644 static/js/components/error-boundary.js create mode 100644 static/js/fetch-interceptor.js create mode 100644 static/js/focus-manager.js create mode 100644 static/js/form-validator.js create mode 100644 static/js/lazy-loader.js create mode 100644 static/js/logger.js create mode 100644 static/js/perf-monitor.js create mode 100644 static/js/router.js create mode 100644 static/js/startup-check.js create mode 100644 static/js/state.js create mode 100644 static/js/utils.js diff --git a/rbox/billing/models.py b/rbox/billing/models.py index 264f7b6..926430a 100644 --- a/rbox/billing/models.py +++ b/rbox/billing/models.py @@ -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) diff --git a/rbox/billing/stripe_client.py b/rbox/billing/stripe_client.py index 1e9211c..6c98f86 100644 --- a/rbox/billing/stripe_client.py +++ b/rbox/billing/stripe_client.py @@ -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) diff --git a/rbox/billing/usage_tracker.py b/rbox/billing/usage_tracker.py index 4e57b28..598c57f 100644 --- a/rbox/billing/usage_tracker.py +++ b/rbox/billing/usage_tracker.py @@ -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: diff --git a/rbox/routers/auth.py b/rbox/routers/auth.py index 4cbc2f9..90c0025 100644 --- a/rbox/routers/auth.py +++ b/rbox/routers/auth.py @@ -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 ) diff --git a/rbox/routers/billing.py b/rbox/routers/billing.py index 45e5551..9a4f102 100644 --- a/rbox/routers/billing.py +++ b/rbox/routers/billing.py @@ -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} diff --git a/static/css/billing.css b/static/css/billing.css index 0bd9e0d..089a354 100644 --- a/static/css/billing.css +++ b/static/css/billing.css @@ -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; +} diff --git a/static/css/code-editor-view.css b/static/css/code-editor-view.css index 23ebb75..c9ccb3d 100644 --- a/static/css/code-editor-view.css +++ b/static/css/code-editor-view.css @@ -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 { diff --git a/static/index.html b/static/index.html index 819cd14..41e895a 100644 --- a/static/index.html +++ b/static/index.html @@ -10,6 +10,7 @@ + diff --git a/static/js/api-contract.js b/static/js/api-contract.js new file mode 100644 index 0000000..5bfa975 --- /dev/null +++ b/static/js/api-contract.js @@ -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; + } + }); + } +} diff --git a/static/js/api.js b/static/js/api.js index 5ef4022..b31a7b7 100644 --- a/static/js/api.js +++ b/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; + } +}); diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..66aa001 --- /dev/null +++ b/static/js/app.js @@ -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; diff --git a/static/js/components/admin-billing.js b/static/js/components/admin-billing.js index 06d93f6..36ca47d 100644 --- a/static/js/components/admin-billing.js +++ b/static/js/components/admin-billing.js @@ -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 = '
Loading admin billing data...
'; + return; + } + + if (this.error) { + this.innerHTML = `
Error: ${this.error}
`; + return; + } + this.innerHTML = `

Billing Administration

diff --git a/static/js/components/base-component.js b/static/js/components/base-component.js new file mode 100644 index 0000000..6ac437d --- /dev/null +++ b/static/js/components/base-component.js @@ -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 ''; + } + + _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) || []; + } +} diff --git a/static/js/components/billing-dashboard.js b/static/js/components/billing-dashboard.js index bfd1702..7c6e23e 100644 --- a/static/js/components/billing-dashboard.js +++ b/static/js/components/billing-dashboard.js @@ -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 = '
Loading billing data...
'; + return; + } + + if (this.error) { + this.innerHTML = `
Error: ${this.error}
`; + 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 = ` + + `; + 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) { diff --git a/static/js/components/code-editor-view.js b/static/js/components/code-editor-view.js index 39014f4..9c318e9 100644 --- a/static/js/components/code-editor-view.js +++ b/static/js/components/code-editor-view.js @@ -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 = ` -
-
-
- -

${this.file.name}

+
+
+
+
+ +

${this.escapeHtml(this.file.name)}

+
+
+ +
-
- +
+
-
- -
`; + + 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(); } } diff --git a/static/js/components/error-boundary.js b/static/js/components/error-boundary.js new file mode 100644 index 0000000..30f944f --- /dev/null +++ b/static/js/components/error-boundary.js @@ -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 ``; + } + + _getTemplate() { + if (!this.error) { + return ''; + } + + return ` + + `; + } + + 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); diff --git a/static/js/components/file-preview.js b/static/js/components/file-preview.js index 144be90..4461902 100644 --- a/static/js/components/file-preview.js +++ b/static/js/components/file-preview.js @@ -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() { diff --git a/static/js/components/login-view.js b/static/js/components/login-view.js index d54abbc..d1bf091 100644 --- a/static/js/components/login-view.js +++ b/static/js/components/login-view.js @@ -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; } } diff --git a/static/js/components/rbox-app.js b/static/js/components/rbox-app.js index fda6cbd..ff1cf08 100644 --- a/static/js/components/rbox-app.js +++ b/static/js/components/rbox-app.js @@ -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 = ` +
+

Failed to Load Application

+

${error.message}

+ +
+ `; + } } 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' + ); + } } } diff --git a/static/js/fetch-interceptor.js b/static/js/fetch-interceptor.js new file mode 100644 index 0000000..663b9c8 --- /dev/null +++ b/static/js/fetch-interceptor.js @@ -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 }); + } + } + } +} diff --git a/static/js/focus-manager.js b/static/js/focus-manager.js new file mode 100644 index 0000000..79b5028 --- /dev/null +++ b/static/js/focus-manager.js @@ -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; + } + } +} diff --git a/static/js/form-validator.js b/static/js/form-validator.js new file mode 100644 index 0000000..2e434c6 --- /dev/null +++ b/static/js/form-validator.js @@ -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 {}; + } +} diff --git a/static/js/lazy-loader.js b/static/js/lazy-loader.js new file mode 100644 index 0000000..6420f42 --- /dev/null +++ b/static/js/lazy-loader.js @@ -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(); + } + } +} diff --git a/static/js/logger.js b/static/js/logger.js new file mode 100644 index 0000000..266cb5a --- /dev/null +++ b/static/js/logger.js @@ -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'); + } + } +} diff --git a/static/js/main.js b/static/js/main.js index 703e106..ff5e48f 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -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 = ` +
+

Application Failed to Initialize

+

Please refresh the page. If the problem persists, check the console for errors.

+ +
+ `; + 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 = ` +
+

Failed to Load Application

+

${error.message}

+ +
+ `; }); } diff --git a/static/js/perf-monitor.js b/static/js/perf-monitor.js new file mode 100644 index 0000000..342e47b --- /dev/null +++ b/static/js/perf-monitor.js @@ -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(); + } +} diff --git a/static/js/router.js b/static/js/router.js new file mode 100644 index 0000000..a7f8b23 --- /dev/null +++ b/static/js/router.js @@ -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 = '

Error loading page

'; + } + } + + _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(); + } +} diff --git a/static/js/startup-check.js b/static/js/startup-check.js new file mode 100644 index 0000000..182ec69 --- /dev/null +++ b/static/js/startup-check.js @@ -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 = ` +
+

Browser Not Supported

+

Your browser does not support the required features for this application.

+ +

Failed Checks:

+
    + ${results.failed.map(f => `
  • ${f}
  • `).join('')} +
+ + ${results.passed.length > 0 ? ` +

Passed Checks:

+
    + ${results.passed.map(p => `
  • ${p}
  • `).join('')} +
+ ` : ''} + +

+ Recommended browsers:
+ Chrome 90+, Firefox 88+, Safari 14+, Edge 90+ +

+
+ `; +} diff --git a/static/js/state.js b/static/js/state.js new file mode 100644 index 0000000..4594b73 --- /dev/null +++ b/static/js/state.js @@ -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); + } + }); + } +} diff --git a/static/js/utils.js b/static/js/utils.js new file mode 100644 index 0000000..410f5a9 --- /dev/null +++ b/static/js/utils.js @@ -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; + } +} diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 1b32b11..ac3c7a8 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -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) \ No newline at end of file