Update.
This commit is contained in:
parent
2325661df4
commit
ba73b8bdf7
@ -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,6 +52,7 @@ class SubscriptionResponse(BaseModel):
|
||||
|
||||
@router.get("/usage/current")
|
||||
async def get_current_usage(current_user: User = Depends(get_current_user)):
|
||||
try:
|
||||
storage_bytes = await UsageTracker.get_current_storage(current_user)
|
||||
today = date.today()
|
||||
|
||||
@ -71,6 +72,8 @@ async def get_current_usage(current_user: User = Depends(get_current_user)):
|
||||
"bandwidth_up_gb_today": 0,
|
||||
"as_of": today.isoformat()
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to fetch usage data: {str(e)}")
|
||||
|
||||
@router.get("/usage/monthly")
|
||||
async def get_monthly_usage(
|
||||
@ -78,17 +81,27 @@ async def get_monthly_usage(
|
||||
month: Optional[int] = None,
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> UsageResponse:
|
||||
try:
|
||||
if year is None or month is None:
|
||||
now = datetime.now()
|
||||
year = now.year
|
||||
month = now.month
|
||||
|
||||
if not (1 <= month <= 12):
|
||||
raise HTTPException(status_code=400, detail="Month must be between 1 and 12")
|
||||
if not (2020 <= year <= 2100):
|
||||
raise HTTPException(status_code=400, detail="Year must be between 2020 and 2100")
|
||||
|
||||
usage = await UsageTracker.get_monthly_usage(current_user, year, month)
|
||||
|
||||
return UsageResponse(
|
||||
**usage,
|
||||
period=f"{year}-{month:02d}"
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to fetch monthly usage: {str(e)}")
|
||||
|
||||
@router.get("/invoices")
|
||||
async def list_invoices(
|
||||
@ -96,6 +109,12 @@ async def list_invoices(
|
||||
offset: int = 0,
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> List[InvoiceResponse]:
|
||||
try:
|
||||
if limit < 1 or limit > 100:
|
||||
raise HTTPException(status_code=400, detail="Limit must be between 1 and 100")
|
||||
if offset < 0:
|
||||
raise HTTPException(status_code=400, detail="Offset must be non-negative")
|
||||
|
||||
invoices = await Invoice.filter(user=current_user).order_by("-created_at").offset(offset).limit(limit).all()
|
||||
|
||||
result = []
|
||||
@ -125,6 +144,10 @@ async def list_invoices(
|
||||
))
|
||||
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to fetch invoices: {str(e)}")
|
||||
|
||||
@router.get("/invoices/{invoice_id}")
|
||||
async def get_invoice(
|
||||
@ -187,6 +210,11 @@ async def get_subscription(current_user: User = Depends(get_current_user)) -> Su
|
||||
|
||||
@router.post("/payment-methods/setup-intent")
|
||||
async def create_setup_intent(current_user: User = Depends(get_current_user)):
|
||||
try:
|
||||
from ..settings import settings
|
||||
if not settings.STRIPE_SECRET_KEY:
|
||||
raise HTTPException(status_code=503, detail="Payment processing not configured")
|
||||
|
||||
subscription = await UserSubscription.get_or_none(user=current_user)
|
||||
|
||||
if not subscription or not subscription.stripe_customer_id:
|
||||
@ -208,6 +236,7 @@ async def create_setup_intent(current_user: User = Depends(get_current_user)):
|
||||
await subscription.save()
|
||||
|
||||
import stripe
|
||||
StripeClient._ensure_api_key()
|
||||
setup_intent = stripe.SetupIntent.create(
|
||||
customer=subscription.stripe_customer_id,
|
||||
payment_method_types=["card"]
|
||||
@ -217,6 +246,10 @@ async def create_setup_intent(current_user: User = Depends(get_current_user)):
|
||||
"client_secret": setup_intent.client_secret,
|
||||
"customer_id": subscription.stripe_customer_id
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to create setup intent: {str(e)}")
|
||||
|
||||
@router.get("/payment-methods")
|
||||
async def list_payment_methods(current_user: User = Depends(get_current_user)):
|
||||
@ -238,18 +271,35 @@ async def list_payment_methods(current_user: User = Depends(get_current_user)):
|
||||
async def stripe_webhook(request: Request):
|
||||
import stripe
|
||||
from ..settings import settings
|
||||
from ..billing.models import BillingEvent
|
||||
|
||||
try:
|
||||
payload = await request.body()
|
||||
sig_header = request.headers.get("stripe-signature")
|
||||
|
||||
if not settings.STRIPE_WEBHOOK_SECRET:
|
||||
raise HTTPException(status_code=503, detail="Webhook secret not configured")
|
||||
|
||||
try:
|
||||
event = stripe.Webhook.construct_event(
|
||||
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid payload")
|
||||
except stripe.error.SignatureVerificationError:
|
||||
raise HTTPException(status_code=400, detail="Invalid signature")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid payload: {str(e)}")
|
||||
except stripe.error.SignatureVerificationError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid signature: {str(e)}")
|
||||
|
||||
event_id = event.get("id")
|
||||
existing_event = await BillingEvent.get_or_none(stripe_event_id=event_id)
|
||||
if existing_event:
|
||||
return JSONResponse(content={"status": "already_processed"})
|
||||
|
||||
await BillingEvent.create(
|
||||
event_type=event["type"],
|
||||
stripe_event_id=event_id,
|
||||
data=event["data"],
|
||||
processed=False
|
||||
)
|
||||
|
||||
if event["type"] == "invoice.payment_succeeded":
|
||||
invoice_data = event["data"]["object"]
|
||||
@ -280,7 +330,12 @@ async def stripe_webhook(request: Request):
|
||||
is_default=True
|
||||
)
|
||||
|
||||
await BillingEvent.filter(stripe_event_id=event_id).update(processed=True)
|
||||
return JSONResponse(content={"status": "success"})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Webhook processing failed: {str(e)}")
|
||||
|
||||
@router.get("/pricing")
|
||||
async def get_pricing():
|
||||
@ -310,3 +365,10 @@ async def list_plans():
|
||||
}
|
||||
for plan in plans
|
||||
]
|
||||
|
||||
@router.get("/stripe-key")
|
||||
async def get_stripe_key():
|
||||
from ..settings import settings
|
||||
if not settings.STRIPE_PUBLISHABLE_KEY:
|
||||
raise HTTPException(status_code=503, detail="Payment processing not configured")
|
||||
return {"publishable_key": settings.STRIPE_PUBLISHABLE_KEY}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
121
static/js/api.js
121
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,7 +22,16 @@ class APIClient {
|
||||
}
|
||||
|
||||
async request(endpoint, options = {}) {
|
||||
this.activeRequests++;
|
||||
this.appState?.setState({ isLoading: true });
|
||||
|
||||
const startTime = performance.now();
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
const method = options.method || 'GET';
|
||||
|
||||
try {
|
||||
this.logger?.debug(`API ${method}: ${endpoint}`);
|
||||
|
||||
const headers = {
|
||||
...options.headers,
|
||||
};
|
||||
@ -27,22 +40,44 @@ class APIClient {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
if (!(options.body instanceof FormData) && options.body) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const config = {
|
||||
...options,
|
||||
headers,
|
||||
};
|
||||
|
||||
if (config.body && !(config.body instanceof FormData)) {
|
||||
if (config.body) {
|
||||
if (config.body instanceof FormData) {
|
||||
let hasFile = false;
|
||||
for (let pair of config.body.entries()) {
|
||||
if (pair[1] instanceof File) {
|
||||
hasFile = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasFile) {
|
||||
this.logger?.error('FormData without File objects not allowed - JSON only communication enforced');
|
||||
throw new Error('FormData is only allowed for file uploads');
|
||||
}
|
||||
|
||||
this.logger?.debug('File upload detected, allowing FormData');
|
||||
} else if (typeof config.body === 'object') {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
config.body = JSON.stringify(config.body);
|
||||
this.logger?.debug('Request body serialized to JSON');
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, config);
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
if (this.perfMonitor) {
|
||||
this.perfMonitor._recordMetric('api-request', duration);
|
||||
this.perfMonitor._recordMetric(`api-${method}`, duration);
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
this.logger?.warn('Unauthorized request, clearing token');
|
||||
this.setToken(null);
|
||||
window.location.href = '/';
|
||||
}
|
||||
@ -55,39 +90,79 @@ class APIClient {
|
||||
errorData = { message: 'Unknown error' };
|
||||
}
|
||||
const errorMessage = errorData.detail || errorData.message || 'Request failed';
|
||||
|
||||
this.logger?.error(`API ${method} ${endpoint} failed: ${errorMessage}`, {
|
||||
status: response.status,
|
||||
duration: `${duration.toFixed(2)}ms`
|
||||
});
|
||||
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: errorMessage, type: 'error' }
|
||||
}));
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
this.logger?.debug(`API ${method} ${endpoint} success`, {
|
||||
status: response.status,
|
||||
duration: `${duration.toFixed(2)}ms`
|
||||
});
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (!contentType || !contentType.includes('application/json')) {
|
||||
this.logger?.error(`Invalid response content-type: ${contentType}. Expected application/json`);
|
||||
throw new Error('Server returned non-JSON response');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
this.logger?.error(`API ${method} ${endpoint} exception`, error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.activeRequests--;
|
||||
if (this.activeRequests === 0) {
|
||||
this.appState?.setState({ isLoading: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async register(username, email, password) {
|
||||
this.logger?.info('Attempting registration', { username, email });
|
||||
|
||||
const data = await this.request('auth/register', {
|
||||
method: 'POST',
|
||||
body: { username, email, password },
|
||||
skipAuth: true
|
||||
});
|
||||
|
||||
if (!data || !data.access_token) {
|
||||
this.logger?.error('Invalid registration response: missing access_token', data);
|
||||
throw new Error('Invalid registration response');
|
||||
}
|
||||
|
||||
this.logger?.info('Registration successful', { username });
|
||||
this.setToken(data.access_token);
|
||||
return data;
|
||||
}
|
||||
|
||||
async login(username, password) {
|
||||
const formData = new FormData();
|
||||
formData.append('username', username);
|
||||
formData.append('password', password);
|
||||
this.logger?.info('Attempting login', { username });
|
||||
|
||||
const data = await this.request('auth/token', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
body: { username, password },
|
||||
skipAuth: true
|
||||
});
|
||||
|
||||
if (!data || !data.access_token) {
|
||||
this.logger?.error('Invalid login response: missing access_token', data);
|
||||
throw new Error('Invalid authentication response');
|
||||
}
|
||||
|
||||
this.logger?.info('Login successful', { username });
|
||||
this.setToken(data.access_token);
|
||||
return data;
|
||||
}
|
||||
@ -328,4 +403,28 @@ class APIClient {
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new APIClient();
|
||||
export { APIClient };
|
||||
|
||||
let _sharedInstance = null;
|
||||
|
||||
export function setSharedAPIInstance(instance) {
|
||||
_sharedInstance = instance;
|
||||
}
|
||||
|
||||
export const api = new Proxy({}, {
|
||||
get(target, prop) {
|
||||
if (!_sharedInstance) {
|
||||
console.warn('API instance accessed before initialization. This may cause issues with logging and state management.');
|
||||
_sharedInstance = new APIClient();
|
||||
}
|
||||
|
||||
const instance = _sharedInstance;
|
||||
const value = instance[prop];
|
||||
|
||||
if (typeof value === 'function') {
|
||||
return value.bind(instance);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
});
|
||||
|
||||
111
static/js/app.js
Normal file
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') {
|
||||
this.file = file;
|
||||
this.previousView = previousView;
|
||||
await this.loadAndRender();
|
||||
if (this.isRendered) {
|
||||
logger.warn('Editor already rendered, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
async loadAndRender() {
|
||||
this.file = file;
|
||||
this.previousView = previousView;
|
||||
this.isRendered = true;
|
||||
|
||||
try {
|
||||
const blob = await api.downloadFile(this.file.id);
|
||||
logger.debug('Loading file', { fileName: file.name });
|
||||
const blob = await api.downloadFile(file.id);
|
||||
const content = await blob.text();
|
||||
this.render(content);
|
||||
this.initializeEditor(content);
|
||||
|
||||
this.createUI(content);
|
||||
this.createEditor(content);
|
||||
} catch (error) {
|
||||
console.error('Failed to load file:', error);
|
||||
logger.error('Failed to load file', error);
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'Failed to load file: ' + error.message, type: 'error' }
|
||||
}));
|
||||
this.render('');
|
||||
window.history.back();
|
||||
}
|
||||
}
|
||||
|
||||
getMimeType(filename) {
|
||||
const extension = filename.split('.').pop().toLowerCase();
|
||||
const mimeMap = {
|
||||
'js': 'text/javascript',
|
||||
'json': 'application/json',
|
||||
'py': 'text/x-python',
|
||||
'md': 'text/x-markdown',
|
||||
'html': 'text/html',
|
||||
'xml': 'application/xml',
|
||||
'css': 'text/css',
|
||||
'txt': 'text/plain',
|
||||
'log': 'text/plain',
|
||||
'sh': 'text/x-sh',
|
||||
'yaml': 'text/x-yaml',
|
||||
'yml': 'text/x-yaml'
|
||||
};
|
||||
return mimeMap[extension] || 'text/plain';
|
||||
}
|
||||
|
||||
render(content) {
|
||||
createUI(content) {
|
||||
this.innerHTML = `
|
||||
<div class="code-editor-view">
|
||||
<div class="code-editor-overlay">
|
||||
<div class="code-editor-container">
|
||||
<div class="code-editor-header">
|
||||
<div class="header-left">
|
||||
<button class="button" id="back-btn">Back</button>
|
||||
<h2 class="editor-filename">${this.file.name}</h2>
|
||||
<h2 class="editor-filename">${this.escapeHtml(this.file.name)}</h2>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button class="button button-primary" id="save-btn">Save</button>
|
||||
<button class="button button-primary" id="save-btn">Save & Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="code-editor-body">
|
||||
<textarea id="code-editor-textarea">${content}</textarea>
|
||||
<textarea id="editor-textarea"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const backBtn = this.querySelector('#back-btn');
|
||||
const saveBtn = this.querySelector('#save-btn');
|
||||
|
||||
backBtn.addEventListener('click', () => this.close());
|
||||
saveBtn.addEventListener('click', () => this.save());
|
||||
|
||||
document.addEventListener('keydown', this.handleKeydown.bind(this));
|
||||
}
|
||||
|
||||
initializeEditor(content) {
|
||||
const textarea = this.querySelector('#code-editor-textarea');
|
||||
if (!textarea) return;
|
||||
createEditor(content) {
|
||||
const textarea = this.querySelector('#editor-textarea');
|
||||
if (!textarea) {
|
||||
logger.error('Textarea not found');
|
||||
return;
|
||||
}
|
||||
|
||||
textarea.value = content;
|
||||
|
||||
const mode = this.getMode(this.file.name);
|
||||
logger.debug('Creating CodeMirror editor', { mode, fileSize: content.length });
|
||||
|
||||
this.editor = CodeMirror.fromTextArea(textarea, {
|
||||
value: content,
|
||||
mode: this.getMimeType(this.file.name),
|
||||
mode: mode,
|
||||
lineNumbers: true,
|
||||
theme: 'default',
|
||||
lineWrapping: true,
|
||||
indentUnit: 4,
|
||||
indentWithTabs: false,
|
||||
theme: 'default',
|
||||
readOnly: false,
|
||||
autofocus: true,
|
||||
extraKeys: {
|
||||
'Ctrl-S': () => this.save(),
|
||||
'Cmd-S': () => this.save()
|
||||
'Ctrl-S': () => { this.save(); return false; },
|
||||
'Cmd-S': () => { this.save(); return false; },
|
||||
'Esc': () => { this.close(); return false; }
|
||||
}
|
||||
});
|
||||
|
||||
this.editor.setSize('100%', '100%');
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.editor) {
|
||||
this.editor.refresh();
|
||||
this.editor.focus();
|
||||
logger.debug('Editor ready and focused');
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
handleClick(e) {
|
||||
if (e.target.id === 'back-btn') {
|
||||
this.goBack();
|
||||
} else if (e.target.id === 'save-btn') {
|
||||
this.save();
|
||||
getMode(filename) {
|
||||
const ext = filename.split('.').pop().toLowerCase();
|
||||
const modes = {
|
||||
'js': 'javascript',
|
||||
'json': { name: 'javascript', json: true },
|
||||
'py': 'python',
|
||||
'md': 'markdown',
|
||||
'html': 'htmlmixed',
|
||||
'xml': 'xml',
|
||||
'css': 'css',
|
||||
'txt': 'text/plain',
|
||||
'log': 'text/plain',
|
||||
'sh': 'shell',
|
||||
'yaml': 'yaml',
|
||||
'yml': 'yaml'
|
||||
};
|
||||
return modes[ext] || 'text/plain';
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
handleKeydown(e) {
|
||||
if (e.key === 'Escape' && !this.editor.getOption('readOnly')) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
async save() {
|
||||
if (!this.editor) return;
|
||||
if (!this.editor) {
|
||||
logger.warn('No editor instance');
|
||||
return;
|
||||
}
|
||||
|
||||
const saveBtn = this.querySelector('#save-btn');
|
||||
if (!saveBtn) return;
|
||||
|
||||
try {
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = 'Saving...';
|
||||
|
||||
const content = this.editor.getValue();
|
||||
logger.debug('Saving file', { fileName: this.file.name, size: content.length });
|
||||
|
||||
await api.updateFile(this.file.id, content);
|
||||
|
||||
logger.info('File saved successfully', { fileName: this.file.name });
|
||||
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'File saved successfully!', type: 'success' }
|
||||
}));
|
||||
|
||||
setTimeout(() => {
|
||||
this.close();
|
||||
}, 500);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to save file', error);
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'Failed to save file: ' + error.message, type: 'error' }
|
||||
detail: { message: 'Failed to save: ' + error.message, type: 'error' }
|
||||
}));
|
||||
|
||||
if (saveBtn) {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = 'Save & Close';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
goBack() {
|
||||
close() {
|
||||
logger.debug('Closing editor');
|
||||
window.history.back();
|
||||
}
|
||||
|
||||
hide() {
|
||||
document.removeEventListener('keydown', this.boundHandleEscape);
|
||||
logger.debug('Hiding editor');
|
||||
this.destroyEditor();
|
||||
this.remove();
|
||||
}
|
||||
|
||||
destroyEditor() {
|
||||
if (this.editor) {
|
||||
logger.debug('Destroying CodeMirror instance');
|
||||
try {
|
||||
this.editor.toTextArea();
|
||||
} catch (e) {
|
||||
logger.warn('Error destroying editor', e);
|
||||
}
|
||||
this.editor = null;
|
||||
}
|
||||
this.remove();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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() {
|
||||
try {
|
||||
await this.init();
|
||||
this.addEventListener('show-toast', this.handleShowToast);
|
||||
window.addEventListener('popstate', this.handlePopState.bind(this));
|
||||
|
||||
if (!this.popstateAttached) {
|
||||
window.addEventListener('popstate', this.boundHandlePopState);
|
||||
this.popstateAttached = true;
|
||||
logger.debug('Popstate listener attached');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize RBoxApp', error);
|
||||
this.innerHTML = `
|
||||
<div style="padding: 2rem; text-align: center; font-family: sans-serif;">
|
||||
<h1 style="color: #d32f2f;">Failed to Load Application</h1>
|
||||
<p>${error.message}</p>
|
||||
<button onclick="location.reload()" style="padding: 0.75rem 1.5rem; background: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem;">
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.removeEventListener('show-toast', this.handleShowToast);
|
||||
if (this.popstateAttached) {
|
||||
window.removeEventListener('popstate', this.boundHandlePopState);
|
||||
this.popstateAttached = false;
|
||||
logger.debug('Popstate listener removed');
|
||||
}
|
||||
}
|
||||
|
||||
handleShowToast = (event) => {
|
||||
@ -46,15 +75,21 @@ export class RBoxApp extends HTMLElement {
|
||||
}
|
||||
|
||||
async init() {
|
||||
try {
|
||||
if (!api.getToken()) {
|
||||
logger.info('No token found, showing login');
|
||||
this.showLogin();
|
||||
} else {
|
||||
try {
|
||||
logger.info('Initializing application with stored token');
|
||||
this.user = await api.getCurrentUser();
|
||||
appState.setState({ user: this.user });
|
||||
logger.info('User loaded successfully', { username: this.user.username });
|
||||
this.render();
|
||||
} catch (error) {
|
||||
this.showLogin();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize application', error);
|
||||
api.setToken(null);
|
||||
this.showLogin();
|
||||
}
|
||||
}
|
||||
|
||||
@ -383,47 +418,63 @@ export class RBoxApp extends HTMLElement {
|
||||
}
|
||||
|
||||
handlePopState(e) {
|
||||
logger.debug('Popstate event', { state: e.state, url: window.location.href });
|
||||
|
||||
this.closeAllOverlays();
|
||||
|
||||
if (e.state && e.state.view) {
|
||||
if (e.state.view === 'code-editor' && e.state.file) {
|
||||
const view = e.state.view;
|
||||
|
||||
if (view === 'code-editor' && e.state.file) {
|
||||
logger.debug('Restoring code editor view');
|
||||
this.showCodeEditor(e.state.file, false);
|
||||
} else if (e.state.view === 'file-preview' && e.state.file) {
|
||||
} else if (view === 'file-preview' && e.state.file) {
|
||||
logger.debug('Restoring file preview view');
|
||||
this.showFilePreview(e.state.file, false);
|
||||
} else if (e.state.view === 'upload') {
|
||||
} else if (view === 'upload') {
|
||||
logger.debug('Restoring upload view');
|
||||
const folderId = e.state.folderId !== undefined ? e.state.folderId : null;
|
||||
this.showUpload(folderId, false);
|
||||
} else {
|
||||
this.switchView(e.state.view, false);
|
||||
logger.debug('Switching to view', { view });
|
||||
this.switchView(view, false);
|
||||
}
|
||||
} else {
|
||||
logger.debug('No state, defaulting to files view');
|
||||
this.switchView('files', false);
|
||||
}
|
||||
}
|
||||
|
||||
closeAllOverlays() {
|
||||
logger.debug('Closing all overlays');
|
||||
|
||||
const existingEditor = this.querySelector('code-editor-view');
|
||||
if (existingEditor) {
|
||||
logger.debug('Hiding code editor');
|
||||
existingEditor.hide();
|
||||
}
|
||||
|
||||
const existingPreview = this.querySelector('file-preview');
|
||||
if (existingPreview) {
|
||||
logger.debug('Hiding file preview');
|
||||
existingPreview.hide();
|
||||
}
|
||||
|
||||
const existingUpload = this.querySelector('file-upload-view');
|
||||
if (existingUpload) {
|
||||
logger.debug('Hiding file upload');
|
||||
existingUpload.hide();
|
||||
}
|
||||
|
||||
const shareModal = this.querySelector('share-modal');
|
||||
if (shareModal && shareModal.style.display !== 'none') {
|
||||
logger.debug('Hiding share modal');
|
||||
shareModal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
showCodeEditor(file, pushState = true) {
|
||||
logger.debug('Showing code editor', { file: file.name, pushState });
|
||||
this.closeAllOverlays();
|
||||
|
||||
const mainElement = this.querySelector('.app-main');
|
||||
@ -432,15 +483,29 @@ export class RBoxApp extends HTMLElement {
|
||||
editorView.setFile(file, this.currentView);
|
||||
|
||||
if (pushState) {
|
||||
const currentState = window.history.state || {};
|
||||
const currentView = currentState.view || this.currentView;
|
||||
|
||||
if (currentView !== 'code-editor') {
|
||||
window.history.pushState(
|
||||
{ view: 'code-editor', file: file },
|
||||
{ view: 'code-editor', file: file, previousView: currentView },
|
||||
'',
|
||||
`#editor/${file.id}`
|
||||
);
|
||||
logger.debug('Pushed code editor state', { previousView: currentView });
|
||||
} else {
|
||||
logger.debug('Already in code editor view, replacing state');
|
||||
window.history.replaceState(
|
||||
{ view: 'code-editor', file: file, previousView: currentView },
|
||||
'',
|
||||
`#editor/${file.id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showFilePreview(file, pushState = true) {
|
||||
logger.debug('Showing file preview', { file: file.name, pushState });
|
||||
this.closeAllOverlays();
|
||||
|
||||
const mainElement = this.querySelector('.app-main');
|
||||
@ -449,15 +514,29 @@ export class RBoxApp extends HTMLElement {
|
||||
preview.show(file, false);
|
||||
|
||||
if (pushState) {
|
||||
const currentState = window.history.state || {};
|
||||
const currentView = currentState.view || this.currentView;
|
||||
|
||||
if (currentView !== 'file-preview') {
|
||||
window.history.pushState(
|
||||
{ view: 'file-preview', file: file },
|
||||
{ view: 'file-preview', file: file, previousView: currentView },
|
||||
'',
|
||||
`#preview/${file.id}`
|
||||
);
|
||||
logger.debug('Pushed file preview state', { previousView: currentView });
|
||||
} else {
|
||||
logger.debug('Already in file preview view, replacing state');
|
||||
window.history.replaceState(
|
||||
{ view: 'file-preview', file: file, previousView: currentView },
|
||||
'',
|
||||
`#preview/${file.id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showUpload(folderId = null, pushState = true) {
|
||||
logger.debug('Showing upload view', { folderId, pushState });
|
||||
this.closeAllOverlays();
|
||||
|
||||
const mainElement = this.querySelector('.app-main');
|
||||
@ -466,11 +545,24 @@ export class RBoxApp extends HTMLElement {
|
||||
uploadView.setFolder(folderId);
|
||||
|
||||
if (pushState) {
|
||||
const currentState = window.history.state || {};
|
||||
const currentView = currentState.view || this.currentView;
|
||||
|
||||
if (currentView !== 'upload') {
|
||||
window.history.pushState(
|
||||
{ view: 'upload', folderId: folderId },
|
||||
{ view: 'upload', folderId: folderId, previousView: currentView },
|
||||
'',
|
||||
'#upload'
|
||||
);
|
||||
logger.debug('Pushed upload state', { previousView: currentView });
|
||||
} else {
|
||||
logger.debug('Already in upload view, replacing state');
|
||||
window.history.replaceState(
|
||||
{ view: 'upload', folderId: folderId, previousView: currentView },
|
||||
'',
|
||||
'#upload'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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) {
|
||||
customElements.define('rbox-app', rboxApp.RBoxApp);
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/static/service-worker.js');
|
||||
navigator.serviceWorker.register('/static/service-worker.js').then(() => {
|
||||
app.getLogger().info('Service worker registered successfully');
|
||||
}).catch((error) => {
|
||||
app.getLogger().error('Service worker registration failed', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
app.getLogger().info('Main application loaded successfully');
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
app.getLazyLoader().observeAll('[data-src]');
|
||||
app.getLogger().info('Application fully ready');
|
||||
});
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error('Failed to load application modules:', error);
|
||||
document.body.innerHTML = `
|
||||
<div style="padding: 2rem; text-align: center; font-family: sans-serif;">
|
||||
<h1 style="color: #d32f2f;">Failed to Load Application</h1>
|
||||
<p>${error.message}</p>
|
||||
<button onclick="location.reload()" style="padding: 0.75rem 1.5rem; background: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem;">
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
71
static/js/perf-monitor.js
Normal file
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