From 2b68dbac20b579347bdb96233506d1a533053279 Mon Sep 17 00:00:00 2001 From: hoshimach1 Date: Sun, 11 Jan 2026 07:07:32 +0300 Subject: [PATCH] Update WebApp --- config.py | 9 +- database.py | 12 + handlers/admin.py | 172 ++- handlers/payment.py | 6 +- keyboards.py | 4 +- main.py | 6 +- server.py | 361 ++++++- states.py | 1 + web_app/static/css/dark-hc.css | 51 + web_app/static/css/dark-mc.css | 51 + web_app/static/css/dark.css | 53 + web_app/static/css/light-hc.css | 51 + web_app/static/css/light-mc.css | 51 + web_app/static/css/light.css | 53 + web_app/static/css/style.css | 1751 ++++++++++++++++++++++--------- web_app/static/index.html | 506 ++++++--- web_app/static/js/app.js | 1187 +++++++++++++++++++-- 17 files changed, 3501 insertions(+), 824 deletions(-) create mode 100644 web_app/static/css/dark-hc.css create mode 100644 web_app/static/css/dark-mc.css create mode 100644 web_app/static/css/dark.css create mode 100644 web_app/static/css/light-hc.css create mode 100644 web_app/static/css/light-mc.css create mode 100644 web_app/static/css/light.css diff --git a/config.py b/config.py index 17b7b38..38523e8 100644 --- a/config.py +++ b/config.py @@ -13,11 +13,12 @@ CONFIG = { "PROVIDER_TOKEN": os.getenv("PROVIDER_TOKEN", ""), "BASE_URL": os.getenv("BASE_URL"), # Внешний домен для VPN (панель Marzban) "WEB_APP_URL": os.getenv("WEB_APP_URL"), # URL для веб-приложения (Mini App) - "WEB_APP_PORT": int(os.getenv("WEB_APP_PORT", 8000)), + "WEB_APP_PORT": int(os.getenv("WEB_APP_PORT", 8888)), } PLANS = { - "month_1": {"name": "1 месяц", "days": 30, "price": 100, "data_limit": 50}, - "month_3": {"name": "3 месяца", "days": 90, "price": 270, "data_limit": 150}, - "month_6": {"name": "6 месяцев", "days": 180, "price": 500, "data_limit": 300}, + "lite": {"name": "Lite (50 GB)", "days": 30, "price": 100, "data_limit": 50}, + "medium": {"name": "Medium (150 GB)", "days": 90, "price": 270, "data_limit": 150}, + "heavy": {"name": "Heavy (600 GB)", "days": 365, "price": 1000, "data_limit": 600}, + "unlimited": {"name": "Безлимитище", "days": 30, "price": 2500, "data_limit": 0}, } diff --git a/database.py b/database.py index 164fcd2..4a8fc1a 100644 --- a/database.py +++ b/database.py @@ -180,6 +180,14 @@ class Database: now = datetime.now() await self.execute("UPDATE users SET last_traffic_reset = $1 WHERE user_id = $2", now, user_id) + async def get_referrals_count(self, user_id: int): + return await self.fetchval("SELECT COUNT(*) FROM users WHERE invited_by = $1", user_id) or 0 + + async def get_user_payments_info(self, user_id: int): + total_amount = await self.fetchval("SELECT SUM(amount) FROM payments WHERE user_id = $1", user_id) or 0 + total_count = await self.fetchval("SELECT COUNT(*) FROM payments WHERE user_id = $1", user_id) or 0 + return {"total_amount": total_amount, "total_count": total_count} + async def remove_subscription(self, user_id: int): await self.execute("UPDATE users SET subscription_until = NULL WHERE user_id = $1", user_id) @@ -237,6 +245,9 @@ class Database: code, discount, uses, created_by, expires_at, is_unlimited, bonus_days, is_sticky ) + async def delete_promo_code(self, code: str): + await self.execute("DELETE FROM promo_codes WHERE code = $1", code) + async def set_user_discount(self, user_id: int, discount: int): await self.execute("UPDATE users SET personal_discount = $1 WHERE user_id = $2", discount, user_id) @@ -281,6 +292,7 @@ class Database: async def decrement_promo_usage(self, code: str): await self.execute("UPDATE promo_codes SET uses_left = uses_left - 1 WHERE code = $1", code) + await self.execute("DELETE FROM promo_codes WHERE code = $1 AND uses_left <= 0", code) async def add_payment(self, user_id: int, plan: str, amount: int, promo_code: str = None): await self.execute( diff --git a/handlers/admin.py b/handlers/admin.py index 2bff553..f256586 100644 --- a/handlers/admin.py +++ b/handlers/admin.py @@ -148,42 +148,47 @@ async def admin_promos(callback: CallbackQuery): if callback.from_user.id not in CONFIG["ADMIN_IDS"]: return promos = await db.get_active_promos() # Only valid ones - text = "🏷 Активные промокоды:\n\n" - if not promos: - text = "Нет активных промокодов." - + text = "🏷 Управление промокодами:\n\n" + kb_buttons = [] + now = datetime.now() for p in promos: - # Обработка даты (SQLite возвращает строку) exp_val = p['expires_at'] exp_dt = None if exp_val: if isinstance(exp_val, str): - try: - exp_dt = datetime.strptime(exp_val, '%Y-%m-%d %H:%M:%S') - except ValueError: - try: - exp_dt = datetime.strptime(exp_val, '%Y-%m-%d %H:%M:%S.%f') - except: - pass + try: exp_dt = datetime.strptime(exp_val, '%Y-%m-%d %H:%M:%S') + except: + try: exp_dt = datetime.strptime(exp_val, '%Y-%m-%d %H:%M:%S.%f') + except: pass elif isinstance(exp_val, datetime): exp_dt = exp_val + # Filter expired + if exp_dt and exp_dt < now: + continue + exp_str = exp_dt.strftime('%d.%m.%Y') if exp_dt else "∞" - - # Получаем значения по ключам (не через get) is_unl = p['is_unlimited'] - type_str = " (VIP ∞)" if is_unl else f" (-{p['discount']}%)" + type_str = " (VIP)" if is_unl else f" (-{p['discount']}%)" text += ( f"🔹 {p['code']}{type_str}\n" - f" Осталось: {p['uses_left']} | До: {exp_str}\n" + f" Осталось: {p['uses_left']} | До: {exp_str}\n\n" ) + # Add delete button for each promo + kb_buttons.append([InlineKeyboardButton(text=f"❌ Удалить {p['code']}", callback_data=f"admin_promo_del_{p['code']}")]) - kb = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="➕ Создать промокод", callback_data="admin_create_promo")], - [InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")] - ]) - await callback.message.edit_text(text, reply_markup=kb, parse_mode="HTML") + kb_buttons.append([InlineKeyboardButton(text="➕ Создать промокод", callback_data="admin_create_promo")]) + kb_buttons.append([InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")]) + + await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=kb_buttons), parse_mode="HTML") + +@router.callback_query(F.data.startswith("admin_promo_del_")) +async def admin_delete_promo_bot(callback: CallbackQuery): + code = callback.data.replace("admin_promo_del_", "") + await db.delete_promo_code(code) + await callback.answer(f"✅ Промокод {code} удален") + await admin_promos(callback) @router.callback_query(F.data == "admin_create_promo") async def start_create_promo(callback: CallbackQuery, state: FSMContext): @@ -510,16 +515,18 @@ async def show_user_panel(message_or_call, user_id): rows = [ [InlineKeyboardButton(text="➕ Продлить", callback_data=f"adm_usr_add_{user_id}"), - InlineKeyboardButton(text="✏️ Лимит", callback_data=f"adm_usr_gb_{user_id}")], - [status_btn, - InlineKeyboardButton(text="🔄 Сброс трафика", callback_data=f"adm_usr_reset_{user_id}")] + InlineKeyboardButton(text="⏳ Уст. срок", callback_data=f"adm_usr_set_{user_id}")], + [InlineKeyboardButton(text="✏️ Лимит ГБ", callback_data=f"adm_usr_gb_{user_id}"), + InlineKeyboardButton(text="🔄 Сброс", callback_data=f"adm_usr_reset_{user_id}")], + [InlineKeyboardButton(text="📋 План (Admin)", callback_data=f"adm_usr_plan_{user_id}"), + InlineKeyboardButton(text="✉️ Написать", callback_data=f"adm_usr_msg_{user_id}")], + [status_btn] ] # Только если есть активная дата подписки if sub_until and isinstance(sub_until, datetime): rows.append([InlineKeyboardButton(text="❌ Удалить подписку", callback_data=f"adm_usr_delsub_{user_id}")]) - rows.append([InlineKeyboardButton(text="✉️ Сообщение", callback_data=f"adm_usr_msg_{user_id}")]) rows.append([InlineKeyboardButton(text="◀️ Назад", callback_data="admin_users_list")]) kb = InlineKeyboardMarkup(inline_keyboard=rows) @@ -590,6 +597,109 @@ async def adm_usr_add_process(message: Message, state: FSMContext): except ValueError: await message.answer("Ошибка. Введите целое число.") +# Set Fixed Expiry +@router.callback_query(F.data.startswith("adm_usr_set_")) +async def adm_usr_set_start(callback: CallbackQuery, state: FSMContext): + user_id = int(callback.data.split("_")[3]) + await state.update_data(target_user_id=user_id) + await callback.message.edit_text( + "Введите общее количество дней подписки от ТЕКУЩЕГО момента:\n" + "0 - Истечет сразу\n" + "36500 - Вечная подписка", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Отмена", callback_data=f"adm_sel_{user_id}")]]) + ) + await state.set_state(AdminUserStates.waiting_for_fixed_days) + +@router.message(AdminUserStates.waiting_for_fixed_days) +async def adm_usr_set_process(message: Message, state: FSMContext): + try: + days = int(message.text) + data = await state.get_data() + user_id = data['target_user_id'] + + if days > 10000: + new_date = datetime(2099, 12, 31) + else: + new_date = datetime.now() + timedelta(days=days) + + await db.execute("UPDATE users SET subscription_until = $1 WHERE user_id = $2", new_date, user_id) + + # Sync to Marzban + user = await db.get_user(user_id) + marz_user = await marzban.get_user(user['marzban_username']) + + # Marzban treats 0 or negative as no limit or infinity? + # Actually in our server.py we used: + days_left = (new_date - datetime.now()).days + 1 if days > 0 else 0 + marz_days = days_left if days < 10000 else 0 + + await marzban.modify_user( + user['marzban_username'], + (user['data_limit'] / (1024**3)), + expire_timestamp=marz_days if days > 0 else 1 # 1 sec if expired + ) + + await message.answer(f"✅ Срок установлен: {days} дней") + await show_user_panel(message, user_id) + await state.clear() + except Exception as e: + await message.answer(f"❌ Ошибка: {e}") + +# Set Plan (All plans visible to admin) +@router.callback_query(F.data.startswith("adm_usr_plan_")) +async def adm_usr_plan_list(callback: CallbackQuery): + user_id = int(callback.data.split("_")[3]) + + kb_btns = [] + # Show ALL plans from CONFIG to admin + for pid, p in PLANS.items(): + kb_btns.append([InlineKeyboardButton(text=f"{p['name']} ({p['days']}d / {p['data_limit'] or '∞'}GB)", + callback_data=f"adm_setplan_{user_id}_{pid}")]) + + kb_btns.append([InlineKeyboardButton(text="◀️ Отмена", callback_data=f"adm_sel_{user_id}")]) + + await callback.message.edit_text( + f"Выберите тарифный план для применения пользователю {user_id}:", + reply_markup=InlineKeyboardMarkup(inline_keyboard=kb_btns) + ) + +@router.callback_query(F.data.startswith("adm_setplan_")) +async def adm_usr_plan_process(callback: CallbackQuery): + parts = callback.data.split("_") + user_id = int(parts[2]) + plan_id = parts[3] + + plan = PLANS.get(plan_id) + if not plan: + await callback.answer("Ошибка: Тариф не найден", show_alert=True) + return + + user = await db.get_user(user_id) + if not user: return + + total_days = plan['days'] + data_limit_gb = plan['data_limit'] + limit_bytes = int(data_limit_gb * (1024**3)) if data_limit_gb > 0 else 999999 * (1024**3) + + # Update DB + await db.execute("UPDATE users SET data_limit = $1 WHERE user_id = $2", limit_bytes, user_id) + new_date = datetime.now() + timedelta(days=total_days) + await db.execute("UPDATE users SET subscription_until = $1 WHERE user_id = $2", new_date, user_id) + + # Sync to Marzban + marz_days = total_days if total_days > 0 else 0 + marz_limit = data_limit_gb if data_limit_gb > 0 else 0 + + try: + await marzban.modify_user(user['marzban_username'], + marz_limit, + marz_days if marz_days > 0 else 1) + await callback.answer(f"✅ План {plan['name']} успешно применен!", show_alert=True) + except Exception as e: + await callback.answer(f"⚠️ БД обновлена, но Marzban вернул ошибку: {e}", show_alert=True) + + await show_user_panel(callback, user_id) + # Reset Traffic @router.callback_query(F.data.startswith("adm_usr_reset_")) async def adm_usr_reset(callback: CallbackQuery): @@ -673,14 +783,18 @@ async def adm_usr_limit_process(message: Message, state: FSMContext): marz_user = await marzban.get_user(user['marzban_username']) expire_ts = marz_user.get('expire') - current_status = marz_user.get('status', 'active') - await marzban.modify_user(user['marzban_username'], limit_gb, status=current_status, expire_timestamp=expire_ts) - limit_bytes = int(limit_gb * 1024 * 1024 * 1024) + # 0 in our logic means unlimited + marz_limit = limit_gb if limit_gb > 0 else 0 + + await marzban.modify_user(user['marzban_username'], marz_limit, status=current_status, expire_timestamp=expire_ts) + + # Store in DB + limit_bytes = int(limit_gb * (1024**3)) if limit_gb > 0 else 999999 * (1024**3) await db.execute("UPDATE users SET data_limit = $1 WHERE user_id = $2", limit_bytes, user_id) - await message.answer(f"✅ Лимит изменен на {limit_gb} GB") + await message.answer(f"✅ Лимит изменен на {limit_gb if limit_gb > 0 else '∞'} GB") await show_user_panel(message, user_id) await state.clear() except ValueError: diff --git a/handlers/payment.py b/handlers/payment.py index 993a4d4..3b31f7a 100644 --- a/handlers/payment.py +++ b/handlers/payment.py @@ -149,9 +149,10 @@ async def process_payment(callback: CallbackQuery, state: FSMContext): await state.clear() else: # Создаем инвойс для Telegram Stars + limit_str = f"{plan['data_limit']} ГБ" if plan['data_limit'] > 0 else "Безлимит" await callback.message.answer_invoice( title=f"Подписка VPN - {plan['name']}", - description=f"Трафик: {plan['data_limit']} ГБ на {plan['days']} дней", + description=f"Трафик: {limit_str} на {plan['days']} дней", payload=f"{plan_id}:{data.get('promo_code', '')}", currency="XTR", # Telegram Stars prices=[LabeledPrice(label=plan['name'], amount=final_price)], @@ -206,10 +207,11 @@ async def successful_payment(message: Message): bonus_days ) + limit_str = f"{plan['data_limit']} ГБ" if plan['data_limit'] > 0 else "Безлимит" await message.answer( f"✅ Оплата успешна!\n\n" f"Ваша подписка активирована на {date_days} дней.\n" - f"Трафик: {plan['data_limit']} ГБ\n" + f"Трафик: {limit_str}\n" f"{sticky_text}\n" f"Получите конфигурацию через меню: 📊 Моя подписка", reply_markup=main_keyboard(message.from_user.id in CONFIG["ADMIN_IDS"]), diff --git a/keyboards.py b/keyboards.py index baae4ef..8abc476 100644 --- a/keyboards.py +++ b/keyboards.py @@ -23,7 +23,9 @@ def main_keyboard(is_admin: bool = False, has_active_sub: bool = False) -> Inlin def plans_keyboard() -> InlineKeyboardMarkup: buttons = [] - for plan_id, plan in PLANS.items(): + # Only show first 3 plans to users + visible_plans = list(PLANS.items())[:3] + for plan_id, plan in visible_plans: buttons.append([ InlineKeyboardButton( text=f"{plan['name']} - {plan['price']} ⭐", diff --git a/main.py b/main.py index 0ead0bd..c49b2ca 100644 --- a/main.py +++ b/main.py @@ -71,8 +71,10 @@ async def main(): web_app.state.bot = bot config = uvicorn.Config(web_app, host="0.0.0.0", port=CONFIG["WEB_APP_PORT"], log_level="info") server = uvicorn.Server(config) - except ImportError: - logger.error("Could not import server or uvicorn") + except Exception as e: + logger.error(f"FAILED to start Web App: {e}") + import traceback + traceback.print_exc() server = None try: diff --git a/server.py b/server.py index e29ea78..11cc5bf 100644 --- a/server.py +++ b/server.py @@ -1,9 +1,11 @@ -from fastapi import FastAPI, HTTPException, Request +from fastapi import FastAPI, HTTPException, Request, Response from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse import uvicorn -from datetime import datetime +import aiohttp +from pydantic import BaseModel +from datetime import datetime, timedelta import logging import json @@ -32,7 +34,9 @@ async def startup(): @app.get("/api/plans") async def get_plans(): plans_list = [] - for pid, p in PLANS.items(): + # Only return first 3 plans for the shop + visible_plans = list(PLANS.items())[:3] + for pid, p in visible_plans: plans_list.append({ "id": pid, **p @@ -40,7 +44,7 @@ async def get_plans(): return plans_list from aiogram.types import LabeledPrice -from pydantic import BaseModel + class BuyPlanRequest(BaseModel): user_id: int @@ -87,9 +91,10 @@ async def create_invoice(req: BuyPlanRequest, request: Request): if final_price < 1: final_price = 1 try: + limit_desc = f"{plan['data_limit']}GB" if plan['data_limit'] > 0 else "Unlimited" invoice_link = await bot.create_invoice_link( title=f"Sub: {plan['name']}", - description=f"{plan['data_limit']}GB / {plan['days']} days", + description=f"{limit_desc} / {plan['days']} days", payload=f"{req.plan_id}:{req.promo_code or ''}", provider_token="", # Empty for Stars currency="XTR", @@ -146,6 +151,22 @@ async def get_user_stats(user_id: int): except Exception as e: logger.error(f"Marzban fetch error: {e}") + # Registration Date + created_at = user['created_at'] + reg_date = "Unknown" + if created_at: + if isinstance(created_at, str): + try: + created_at = datetime.fromisoformat(created_at) + except: + pass + if isinstance(created_at, datetime): + reg_date = created_at.strftime("%d.%m.%Y") + + # Referral and Payment Stats + ref_count = await db.get_referrals_count(user_id) + payments_info = await db.get_user_payments_info(user_id) + return { "status": status, "days_left": days_left, @@ -155,9 +176,337 @@ async def get_user_stats(user_id: int): "plan": "Premium", "subscription_url": sub_url, "username": user['username'], - "marzban_username": user['marzban_username'] + "marzban_username": user['marzban_username'], + "photo_url": f"/api/user-photo/{user_id}", + "reg_date": reg_date, + "referrals_count": ref_count, + "total_payments": payments_info["total_count"], + "total_spent": payments_info["total_amount"], + "is_admin": is_admin(user_id) } +@app.get("/api/user-photo/{user_id}") +async def get_user_photo(user_id: int): + # This is a proxy to get TG photo without leaking BOT_TOKEN + async with aiohttp.ClientSession() as session: + # 1. Get user profile photos + get_photos_url = f"https://api.telegram.org/bot{CONFIG['BOT_TOKEN']}/getUserProfilePhotos?user_id={user_id}&limit=1" + async with session.get(get_photos_url) as resp: + if resp.status != 200: + return Response(status_code=404) + data = await resp.json() + + if not data.get('ok') or not data['result']['photos']: + return Response(status_code=404) + + file_id = data['result']['photos'][0][0]['file_id'] + + # 2. Get file path + get_file_url = f"https://api.telegram.org/bot{CONFIG['BOT_TOKEN']}/getFile?file_id={file_id}" + async with session.get(get_file_url) as resp: + if resp.status != 200: + return Response(status_code=404) + file_data = await resp.json() + + if not file_data.get('ok'): + return Response(status_code=404) + + file_path = file_data['result']['file_path'] + + # 3. Download and stream the file + file_url = f"https://api.telegram.org/file/bot{CONFIG['BOT_TOKEN']}/{file_path}" + async with session.get(file_url) as resp: + if resp.status != 200: + return Response(status_code=404) + content = await resp.read() + return Response(content=content, media_type="image/jpeg") + +# ... existing code ... + +class SupportRequest(BaseModel): + user_id: int + message: str + username: str = "Unknown" + +@app.post("/api/support") +async def send_support_message(req: SupportRequest, request: Request): + bot = getattr(request.app.state, "bot", None) + if not bot: + return JSONResponse(status_code=500, content={"error": "Bot not init"}) + + # Send to admins + for admin_id in CONFIG["ADMIN_IDS"]: + try: + text = f"📩 **Support Request**\nFrom: @{req.username} (ID: {req.user_id})\n\n{req.message}" + await bot.send_message(chat_id=admin_id, text=text, parse_mode="Markdown") + except Exception as e: + logger.error(f"Failed to send support msg to {admin_id}: {e}") + + return {"status": "sent"} + +# --- ADMIN API --- + +def is_admin(user_id: int): + return user_id in CONFIG["ADMIN_IDS"] + +@app.get("/api/admin/stats") +async def get_admin_stats(user_id: int): + if not is_admin(user_id): + return JSONResponse(status_code=403, content={"error": "Forbidden"}) + + db_stats = await db.get_stats() + try: + sys_stats = await marzban.get_system_stats() + marz_user_stats = await marzban.get_users_stats() + except: + sys_stats = {} + marz_user_stats = {} + + return { + "bot": db_stats, + "server": { + "cpu": sys_stats.get('cpu_usage', 'N/A'), + "ram_used": round(sys_stats.get('mem_used', 0) / (1024**3), 2), + "ram_total": round(sys_stats.get('mem_total', 0) / (1024**3), 2), + "active_users": marz_user_stats.get('active_users', 0), + "total_traffic_gb": round(marz_user_stats.get('total_usage', 0) / (1024**3), 2) + } + } + +@app.get("/api/admin/users") +async def admin_list_users(user_id: int, query: str = None): + if not is_admin(user_id): + return JSONResponse(status_code=403, content={"error": "Forbidden"}) + + if query: + users = await db.search_users(query) + else: + users = await db.get_all_users() + + # Convert to dict and add photo_url + results = [] + for u in users: + d = dict(u) + d['photo_url'] = f"/api/user-photo/{u['user_id']}" + results.append(d) + + # Sort alphabetically by username (case-insensitive), or ID if username is missing + results.sort(key=lambda x: (x.get('username') or str(x['user_id'])).lower()) + + # Return first 50 results + return results[:50] + +@app.get("/api/admin/user/{target_id}") +async def admin_get_user(target_id: int, user_id: int): + if not is_admin(user_id): + return JSONResponse(status_code=403, content={"error": "Forbidden"}) + + user = await db.get_user(target_id) + if not user: + return JSONResponse(status_code=404, content={"error": "User not found"}) + + marz_info = {} + try: + marz_info = await marzban.get_user(user['marzban_username']) + except: + pass + + return { + "user": dict(user), + "marzban": marz_info + } + +@app.post("/api/admin/user/{target_id}/action") +async def admin_user_action(target_id: int, req: Request): + data = await req.json() + admin_id = data.get("user_id") + if not is_admin(admin_id): + return JSONResponse(status_code=403, content={"error": "Forbidden"}) + + action = data.get("action") + user = await db.get_user(target_id) + if not user: + return JSONResponse(status_code=404, content={"error": "User not found"}) + + try: + if action == "reset_traffic": + await marzban.reset_user_traffic(user['marzban_username']) + elif action == "toggle_status": + marz_user = await marzban.get_user(user['marzban_username']) + new_status = "disabled" if marz_user.get('status') == 'active' else 'active' + limit_gb = (marz_user.get('data_limit') or 0) / (1024**3) + await marzban.modify_user(user['marzban_username'], + limit_gb=limit_gb, + status=new_status, + expire_timestamp=marz_user.get('expire')) + elif action == "add_days": + days = int(data.get("days", 0)) + current_limit = user['data_limit'] or 0 + await db.update_subscription(target_id, days, current_limit) + + # Sync to Marzban + u = await db.get_user(target_id) + sub_until = u['subscription_until'] + if isinstance(sub_until, str): sub_until = datetime.fromisoformat(sub_until) + days_left = (sub_until - datetime.now()).days + 1 if sub_until else 0 + await marzban.modify_user(u['marzban_username'], (current_limit / (1024**3)), days_left) + + elif action == "set_limit": + limit_gb = float(data.get("limit_gb", 0)) + marz_user = await marzban.get_user(user['marzban_username']) + # 0 and negative in Marzban is unlimited + marz_limit = limit_gb if limit_gb > 0 else 0 + await marzban.modify_user(user['marzban_username'], marz_limit, + status=marz_user.get('status'), + expire_timestamp=marz_user.get('expire')) + # If 0, we store a very large number in DB to represent infinity + limit_bytes = int(limit_gb * (1024**3)) if limit_gb > 0 else 999999 * (1024**3) + await db.execute("UPDATE users SET data_limit = $1 WHERE user_id = $2", limit_bytes, target_id) + + elif action == "set_expiry": + days = int(data.get("days", 0)) + # Set fixed expiry from NOW + if days > 10000: + new_date = datetime(2099, 12, 31) + else: + new_date = datetime.now() + timedelta(days=days) + + await db.execute("UPDATE users SET subscription_until = $1 WHERE user_id = $2", new_date, target_id) + + # Sync to Marzban + u = await db.get_user(target_id) + marz_user = await marzban.get_user(u['marzban_username']) + # In Marzban, we pass days left + days_left = (new_date - datetime.now()).days + 1 if days > 0 else 0 + # If days > 10000 (forever), Marzban should be None + marz_days = days_left if days < 10000 else 0 + + await marzban.modify_user(u['marzban_username'], + (u['data_limit'] / (1024**3)), + marz_days if days > 0 else 1) # 1 sec if expire + + elif action == "set_plan": + plan_id = data.get("plan_id") + plan = PLANS.get(plan_id) + if not plan: + return JSONResponse(status_code=404, content={"error": "Plan not found"}) + + # Use grant_subscription logic + total_days = plan['days'] + data_limit_gb = plan['data_limit'] + limit_bytes = int(data_limit_gb * (1024**3)) if data_limit_gb > 0 else 999999 * (1024**3) + + await db.execute("UPDATE users SET data_limit = $1 WHERE user_id = $2", limit_bytes, target_id) + # Update expiry relative to now + new_date = datetime.now() + timedelta(days=total_days) + await db.execute("UPDATE users SET subscription_until = $1 WHERE user_id = $2", new_date, target_id) + + # Sync to Marzban + marz_days = total_days if total_days > 0 else 0 + marz_limit = data_limit_gb if data_limit_gb > 0 else 0 + + await marzban.modify_user(user['marzban_username'], + marz_limit, + marz_days if marz_days > 0 else 1) + + elif action == "delete_sub": + await db.remove_subscription(target_id) + expire_ts = int(datetime.now().timestamp()) + marz_user = await marzban.get_user(user['marzban_username']) + limit_gb = (marz_user.get('data_limit') or 0) / (1024**3) + await marzban.modify_user(user['marzban_username'], + limit_gb, + expire_timestamp=expire_ts) + else: + return JSONResponse(status_code=400, content={"error": "Invalid action"}) + + return {"status": "ok"} + except Exception as e: + logger.error(f"Admin action error: {e}") + return JSONResponse(status_code=500, content={"error": str(e)}) + +@app.get("/api/admin/promos") +async def admin_list_promos(user_id: int): + if not is_admin(user_id): + return JSONResponse(status_code=403, content={"error": "Forbidden"}) + promos = await db.fetch("SELECT * FROM promo_codes") + return [dict(p) for p in promos] + +@app.get("/api/admin/plans_full") +async def admin_get_plans_full(user_id: int): + if not is_admin(user_id): + return JSONResponse(status_code=403, content={"error": "Forbidden"}) + plans_list = [] + for pid, p in PLANS.items(): + plans_list.append({"id": pid, **p}) + return plans_list + +class CreatePromoRequest(BaseModel): + user_id: int + code: str + discount: int + uses: int + days: int + is_unlimited: bool = False + bonus_days: int = 0 + is_sticky: bool = False + +@app.post("/api/admin/promos/create") +async def admin_create_promo(req: CreatePromoRequest): + if not is_admin(req.user_id): + return JSONResponse(status_code=403, content={"error": "Forbidden"}) + + expires_at = None + if req.days > 0: + expires_at = datetime.now() + timedelta(days=req.days) + + try: + await db.create_promo_code( + req.code.upper().strip(), + req.discount, + req.uses, + req.user_id, + expires_at, + req.is_unlimited, + req.bonus_days, + req.is_sticky + ) + return {"status": "ok"} + except Exception as e: + return JSONResponse(status_code=500, content={"error": str(e)}) + +@app.delete("/api/admin/promo/{code}") +async def admin_delete_promo(code: str, user_id: int): + if not is_admin(user_id): + return JSONResponse(status_code=403, content={"error": "Forbidden"}) + + await db.delete_promo_code(code) + return {"status": "ok"} + +class BroadcastRequest(BaseModel): + user_id: int + message: str + +@app.post("/api/admin/broadcast") +async def admin_broadcast(req: BroadcastRequest, request: Request): + if not is_admin(req.user_id): + return JSONResponse(status_code=403, content={"error": "Forbidden"}) + + bot = getattr(request.app.state, "bot", None) + if not bot: + return JSONResponse(status_code=500, content={"error": "Bot not init"}) + + users = await db.get_users_for_broadcast() + count = 0 + for u in users: + try: + await bot.send_message(chat_id=u['user_id'], text=req.message) + count += 1 + except: + pass + + return {"sent": count} + # Serve Static Files (must be last) app.mount("/", StaticFiles(directory="web_app/static", html=True), name="static") diff --git a/states.py b/states.py index 4d7ab86..eb281a0 100644 --- a/states.py +++ b/states.py @@ -20,3 +20,4 @@ class AdminUserStates(StatesGroup): waiting_for_days = State() waiting_for_message = State() waiting_for_limit = State() + waiting_for_fixed_days = State() diff --git a/web_app/static/css/dark-hc.css b/web_app/static/css/dark-hc.css new file mode 100644 index 0000000..5737955 --- /dev/null +++ b/web_app/static/css/dark-hc.css @@ -0,0 +1,51 @@ +.dark-high-contrast { + --md-sys-color-primary: rgb(235 240 255); + --md-sys-color-surface-tint: rgb(170 199 255); + --md-sys-color-on-primary: rgb(0 0 0); + --md-sys-color-primary-container: rgb(166 195 252); + --md-sys-color-on-primary-container: rgb(0 11 32); + --md-sys-color-secondary: rgb(235 240 255); + --md-sys-color-on-secondary: rgb(0 0 0); + --md-sys-color-secondary-container: rgb(186 195 216); + --md-sys-color-on-secondary-container: rgb(3 11 26); + --md-sys-color-tertiary: rgb(255 233 255); + --md-sys-color-on-tertiary: rgb(0 0 0); + --md-sys-color-tertiary-container: rgb(216 184 220); + --md-sys-color-on-tertiary-container: rgb(22 4 29); + --md-sys-color-error: rgb(255 236 233); + --md-sys-color-on-error: rgb(0 0 0); + --md-sys-color-error-container: rgb(255 174 164); + --md-sys-color-on-error-container: rgb(34 0 1); + --md-sys-color-background: rgb(17 19 24); + --md-sys-color-on-background: rgb(226 226 233); + --md-sys-color-surface: rgb(17 19 24); + --md-sys-color-on-surface: rgb(255 255 255); + --md-sys-color-surface-variant: rgb(68 71 78); + --md-sys-color-on-surface-variant: rgb(255 255 255); + --md-sys-color-outline: rgb(238 239 249); + --md-sys-color-outline-variant: rgb(192 194 204); + --md-sys-color-shadow: rgb(0 0 0); + --md-sys-color-scrim: rgb(0 0 0); + --md-sys-color-inverse-surface: rgb(226 226 233); + --md-sys-color-inverse-on-surface: rgb(0 0 0); + --md-sys-color-inverse-primary: rgb(41 72 120); + --md-sys-color-primary-fixed: rgb(214 227 255); + --md-sys-color-on-primary-fixed: rgb(0 0 0); + --md-sys-color-primary-fixed-dim: rgb(170 199 255); + --md-sys-color-on-primary-fixed-variant: rgb(0 17 43); + --md-sys-color-secondary-fixed: rgb(218 226 249); + --md-sys-color-on-secondary-fixed: rgb(0 0 0); + --md-sys-color-secondary-fixed-dim: rgb(190 198 220); + --md-sys-color-on-secondary-fixed-variant: rgb(8 17 33); + --md-sys-color-tertiary-fixed: rgb(250 216 253); + --md-sys-color-on-tertiary-fixed: rgb(0 0 0); + --md-sys-color-tertiary-fixed-dim: rgb(221 188 224); + --md-sys-color-on-tertiary-fixed-variant: rgb(29 8 35); + --md-sys-color-surface-dim: rgb(17 19 24); + --md-sys-color-surface-bright: rgb(78 80 86); + --md-sys-color-surface-container-lowest: rgb(0 0 0); + --md-sys-color-surface-container-low: rgb(29 32 36); + --md-sys-color-surface-container: rgb(46 48 54); + --md-sys-color-surface-container-high: rgb(57 59 65); + --md-sys-color-surface-container-highest: rgb(69 71 76); +} diff --git a/web_app/static/css/dark-mc.css b/web_app/static/css/dark-mc.css new file mode 100644 index 0000000..a6bea38 --- /dev/null +++ b/web_app/static/css/dark-mc.css @@ -0,0 +1,51 @@ +.dark-medium-contrast { + --md-sys-color-primary: rgb(205 221 255); + --md-sys-color-surface-tint: rgb(170 199 255); + --md-sys-color-on-primary: rgb(0 37 81); + --md-sys-color-primary-container: rgb(116 145 199); + --md-sys-color-on-primary-container: rgb(0 0 0); + --md-sys-color-secondary: rgb(212 220 242); + --md-sys-color-on-secondary: rgb(29 38 54); + --md-sys-color-secondary-container: rgb(136 145 165); + --md-sys-color-on-secondary-container: rgb(0 0 0); + --md-sys-color-tertiary: rgb(243 210 247); + --md-sys-color-on-tertiary: rgb(51 29 57); + --md-sys-color-tertiary-container: rgb(164 135 169); + --md-sys-color-on-tertiary-container: rgb(0 0 0); + --md-sys-color-error: rgb(255 210 204); + --md-sys-color-on-error: rgb(84 0 3); + --md-sys-color-error-container: rgb(255 84 73); + --md-sys-color-on-error-container: rgb(0 0 0); + --md-sys-color-background: rgb(17 19 24); + --md-sys-color-on-background: rgb(226 226 233); + --md-sys-color-surface: rgb(17 19 24); + --md-sys-color-on-surface: rgb(255 255 255); + --md-sys-color-surface-variant: rgb(68 71 78); + --md-sys-color-on-surface-variant: rgb(218 220 230); + --md-sys-color-outline: rgb(175 178 187); + --md-sys-color-outline-variant: rgb(142 144 153); + --md-sys-color-shadow: rgb(0 0 0); + --md-sys-color-scrim: rgb(0 0 0); + --md-sys-color-inverse-surface: rgb(226 226 233); + --md-sys-color-inverse-on-surface: rgb(40 42 47); + --md-sys-color-inverse-primary: rgb(41 72 120); + --md-sys-color-primary-fixed: rgb(214 227 255); + --md-sys-color-on-primary-fixed: rgb(0 17 43); + --md-sys-color-primary-fixed-dim: rgb(170 199 255); + --md-sys-color-on-primary-fixed-variant: rgb(19 54 101); + --md-sys-color-secondary-fixed: rgb(218 226 249); + --md-sys-color-on-secondary-fixed: rgb(8 17 33); + --md-sys-color-secondary-fixed-dim: rgb(190 198 220); + --md-sys-color-on-secondary-fixed-variant: rgb(46 54 71); + --md-sys-color-tertiary-fixed: rgb(250 216 253); + --md-sys-color-on-tertiary-fixed: rgb(29 8 35); + --md-sys-color-tertiary-fixed-dim: rgb(221 188 224); + --md-sys-color-on-tertiary-fixed-variant: rgb(69 46 74); + --md-sys-color-surface-dim: rgb(17 19 24); + --md-sys-color-surface-bright: rgb(67 68 74); + --md-sys-color-surface-container-lowest: rgb(6 7 12); + --md-sys-color-surface-container-low: rgb(27 30 34); + --md-sys-color-surface-container: rgb(38 40 45); + --md-sys-color-surface-container-high: rgb(49 50 56); + --md-sys-color-surface-container-highest: rgb(60 62 67); +} diff --git a/web_app/static/css/dark.css b/web_app/static/css/dark.css new file mode 100644 index 0000000..15bb415 --- /dev/null +++ b/web_app/static/css/dark.css @@ -0,0 +1,53 @@ +/* Dark Theme Tokens */ +body.dark, +.dark { + --md-sys-color-primary: rgb(170 199 255); + --md-sys-color-surface-tint: rgb(170 199 255); + --md-sys-color-on-primary: rgb(10 48 95); + --md-sys-color-primary-container: rgb(40 71 119); + --md-sys-color-on-primary-container: rgb(214 227 255); + --md-sys-color-secondary: rgb(190 198 220); + --md-sys-color-on-secondary: rgb(40 49 65); + --md-sys-color-secondary-container: rgb(62 71 89); + --md-sys-color-on-secondary-container: rgb(218 226 249); + --md-sys-color-tertiary: rgb(221 188 224); + --md-sys-color-on-tertiary: rgb(63 40 68); + --md-sys-color-tertiary-container: rgb(87 62 92); + --md-sys-color-on-tertiary-container: rgb(250 216 253); + --md-sys-color-error: rgb(255 180 171); + --md-sys-color-on-error: rgb(105 0 5); + --md-sys-color-error-container: rgb(147 0 10); + --md-sys-color-on-error-container: rgb(255 218 214); + --md-sys-color-background: rgb(17 19 24); + --md-sys-color-on-background: rgb(226 226 233); + --md-sys-color-surface: rgb(17 19 24); + --md-sys-color-on-surface: rgb(226 226 233); + --md-sys-color-surface-variant: rgb(68 71 78); + --md-sys-color-on-surface-variant: rgb(196 198 208); + --md-sys-color-outline: rgb(142 144 153); + --md-sys-color-outline-variant: rgb(68 71 78); + --md-sys-color-shadow: rgb(0 0 0); + --md-sys-color-scrim: rgb(0 0 0); + --md-sys-color-inverse-surface: rgb(226 226 233); + --md-sys-color-inverse-on-surface: rgb(46 48 54); + --md-sys-color-inverse-primary: rgb(65 95 145); + --md-sys-color-primary-fixed: rgb(214 227 255); + --md-sys-color-on-primary-fixed: rgb(0 27 62); + --md-sys-color-primary-fixed-dim: rgb(170 199 255); + --md-sys-color-on-primary-fixed-variant: rgb(40 71 119); + --md-sys-color-secondary-fixed: rgb(218 226 249); + --md-sys-color-on-secondary-fixed: rgb(19 28 43); + --md-sys-color-secondary-fixed-dim: rgb(190 198 220); + --md-sys-color-on-secondary-fixed-variant: rgb(62 71 89); + --md-sys-color-tertiary-fixed: rgb(250 216 253); + --md-sys-color-on-tertiary-fixed: rgb(40 19 46); + --md-sys-color-tertiary-fixed-dim: rgb(221 188 224); + --md-sys-color-on-tertiary-fixed-variant: rgb(87 62 92); + --md-sys-color-surface-dim: rgb(17 19 24); + --md-sys-color-surface-bright: rgb(55 57 62); + --md-sys-color-surface-container-lowest: rgb(12 14 19); + --md-sys-color-surface-container-low: rgb(25 28 32); + --md-sys-color-surface-container: rgb(29 32 36); + --md-sys-color-surface-container-high: rgb(40 42 47); + --md-sys-color-surface-container-highest: rgb(51 53 58); +} \ No newline at end of file diff --git a/web_app/static/css/light-hc.css b/web_app/static/css/light-hc.css new file mode 100644 index 0000000..a8f68d9 --- /dev/null +++ b/web_app/static/css/light-hc.css @@ -0,0 +1,51 @@ +.light-high-contrast { + --md-sys-color-primary: rgb(3 43 91); + --md-sys-color-surface-tint: rgb(65 95 145); + --md-sys-color-on-primary: rgb(255 255 255); + --md-sys-color-primary-container: rgb(42 73 122); + --md-sys-color-on-primary-container: rgb(255 255 255); + --md-sys-color-secondary: rgb(35 44 61); + --md-sys-color-on-secondary: rgb(255 255 255); + --md-sys-color-secondary-container: rgb(65 73 91); + --md-sys-color-on-secondary-container: rgb(255 255 255); + --md-sys-color-tertiary: rgb(58 36 64); + --md-sys-color-on-tertiary: rgb(255 255 255); + --md-sys-color-tertiary-container: rgb(89 64 94); + --md-sys-color-on-tertiary-container: rgb(255 255 255); + --md-sys-color-error: rgb(96 0 4); + --md-sys-color-on-error: rgb(255 255 255); + --md-sys-color-error-container: rgb(152 0 10); + --md-sys-color-on-error-container: rgb(255 255 255); + --md-sys-color-background: rgb(249 249 255); + --md-sys-color-on-background: rgb(25 28 32); + --md-sys-color-surface: rgb(249 249 255); + --md-sys-color-on-surface: rgb(0 0 0); + --md-sys-color-surface-variant: rgb(224 226 236); + --md-sys-color-on-surface-variant: rgb(0 0 0); + --md-sys-color-outline: rgb(41 44 51); + --md-sys-color-outline-variant: rgb(70 73 81); + --md-sys-color-shadow: rgb(0 0 0); + --md-sys-color-scrim: rgb(0 0 0); + --md-sys-color-inverse-surface: rgb(46 48 54); + --md-sys-color-inverse-on-surface: rgb(255 255 255); + --md-sys-color-inverse-primary: rgb(170 199 255); + --md-sys-color-primary-fixed: rgb(42 73 122); + --md-sys-color-on-primary-fixed: rgb(255 255 255); + --md-sys-color-primary-fixed-dim: rgb(14 50 98); + --md-sys-color-on-primary-fixed-variant: rgb(255 255 255); + --md-sys-color-secondary-fixed: rgb(65 73 91); + --md-sys-color-on-secondary-fixed: rgb(255 255 255); + --md-sys-color-secondary-fixed-dim: rgb(42 51 68); + --md-sys-color-on-secondary-fixed-variant: rgb(255 255 255); + --md-sys-color-tertiary-fixed: rgb(89 64 94); + --md-sys-color-on-tertiary-fixed: rgb(255 255 255); + --md-sys-color-tertiary-fixed-dim: rgb(65 42 71); + --md-sys-color-on-tertiary-fixed-variant: rgb(255 255 255); + --md-sys-color-surface-dim: rgb(184 184 191); + --md-sys-color-surface-bright: rgb(249 249 255); + --md-sys-color-surface-container-lowest: rgb(255 255 255); + --md-sys-color-surface-container-low: rgb(240 240 247); + --md-sys-color-surface-container: rgb(226 226 233); + --md-sys-color-surface-container-high: rgb(211 212 219); + --md-sys-color-surface-container-highest: rgb(197 198 205); +} diff --git a/web_app/static/css/light-mc.css b/web_app/static/css/light-mc.css new file mode 100644 index 0000000..6361963 --- /dev/null +++ b/web_app/static/css/light-mc.css @@ -0,0 +1,51 @@ +.light-medium-contrast { + --md-sys-color-primary: rgb(19 54 101); + --md-sys-color-surface-tint: rgb(65 95 145); + --md-sys-color-on-primary: rgb(255 255 255); + --md-sys-color-primary-container: rgb(80 109 160); + --md-sys-color-on-primary-container: rgb(255 255 255); + --md-sys-color-secondary: rgb(46 54 71); + --md-sys-color-on-secondary: rgb(255 255 255); + --md-sys-color-secondary-container: rgb(100 109 128); + --md-sys-color-on-secondary-container: rgb(255 255 255); + --md-sys-color-tertiary: rgb(69 46 74); + --md-sys-color-on-tertiary: rgb(255 255 255); + --md-sys-color-tertiary-container: rgb(127 100 132); + --md-sys-color-on-tertiary-container: rgb(255 255 255); + --md-sys-color-error: rgb(116 0 6); + --md-sys-color-on-error: rgb(255 255 255); + --md-sys-color-error-container: rgb(207 44 39); + --md-sys-color-on-error-container: rgb(255 255 255); + --md-sys-color-background: rgb(249 249 255); + --md-sys-color-on-background: rgb(25 28 32); + --md-sys-color-surface: rgb(249 249 255); + --md-sys-color-on-surface: rgb(15 17 22); + --md-sys-color-surface-variant: rgb(224 226 236); + --md-sys-color-on-surface-variant: rgb(51 54 62); + --md-sys-color-outline: rgb(79 82 90); + --md-sys-color-outline-variant: rgb(106 109 117); + --md-sys-color-shadow: rgb(0 0 0); + --md-sys-color-scrim: rgb(0 0 0); + --md-sys-color-inverse-surface: rgb(46 48 54); + --md-sys-color-inverse-on-surface: rgb(240 240 247); + --md-sys-color-inverse-primary: rgb(170 199 255); + --md-sys-color-primary-fixed: rgb(80 109 160); + --md-sys-color-on-primary-fixed: rgb(255 255 255); + --md-sys-color-primary-fixed-dim: rgb(55 85 134); + --md-sys-color-on-primary-fixed-variant: rgb(255 255 255); + --md-sys-color-secondary-fixed: rgb(100 109 128); + --md-sys-color-on-secondary-fixed: rgb(255 255 255); + --md-sys-color-secondary-fixed-dim: rgb(76 85 103); + --md-sys-color-on-secondary-fixed-variant: rgb(255 255 255); + --md-sys-color-tertiary-fixed: rgb(127 100 132); + --md-sys-color-on-tertiary-fixed: rgb(255 255 255); + --md-sys-color-tertiary-fixed-dim: rgb(101 76 107); + --md-sys-color-on-tertiary-fixed-variant: rgb(255 255 255); + --md-sys-color-surface-dim: rgb(197 198 205); + --md-sys-color-surface-bright: rgb(249 249 255); + --md-sys-color-surface-container-lowest: rgb(255 255 255); + --md-sys-color-surface-container-low: rgb(243 243 250); + --md-sys-color-surface-container: rgb(231 232 238); + --md-sys-color-surface-container-high: rgb(220 220 227); + --md-sys-color-surface-container-highest: rgb(209 209 216); +} diff --git a/web_app/static/css/light.css b/web_app/static/css/light.css new file mode 100644 index 0000000..25f8499 --- /dev/null +++ b/web_app/static/css/light.css @@ -0,0 +1,53 @@ +/* Light Theme Tokens */ +body.light, +.light { + --md-sys-color-primary: rgb(65 95 145); + --md-sys-color-surface-tint: rgb(65 95 145); + --md-sys-color-on-primary: rgb(255 255 255); + --md-sys-color-primary-container: rgb(214 227 255); + --md-sys-color-on-primary-container: rgb(40 71 119); + --md-sys-color-secondary: rgb(86 95 113); + --md-sys-color-on-secondary: rgb(255 255 255); + --md-sys-color-secondary-container: rgb(218 226 249); + --md-sys-color-on-secondary-container: rgb(62 71 89); + --md-sys-color-tertiary: rgb(112 85 117); + --md-sys-color-on-tertiary: rgb(255 255 255); + --md-sys-color-tertiary-container: rgb(250 216 253); + --md-sys-color-on-tertiary-container: rgb(87 62 92); + --md-sys-color-error: rgb(186 26 26); + --md-sys-color-on-error: rgb(255 255 255); + --md-sys-color-error-container: rgb(255 218 214); + --md-sys-color-on-error-container: rgb(147 0 10); + --md-sys-color-background: rgb(249 249 255); + --md-sys-color-on-background: rgb(25 28 32); + --md-sys-color-surface: rgb(249 249 255); + --md-sys-color-on-surface: rgb(25 28 32); + --md-sys-color-surface-variant: rgb(224 226 236); + --md-sys-color-on-surface-variant: rgb(68 71 78); + --md-sys-color-outline: rgb(116 119 127); + --md-sys-color-outline-variant: rgb(196 198 208); + --md-sys-color-shadow: rgb(0 0 0); + --md-sys-color-scrim: rgb(0 0 0); + --md-sys-color-inverse-surface: rgb(46 48 54); + --md-sys-color-inverse-on-surface: rgb(240 240 247); + --md-sys-color-inverse-primary: rgb(170 199 255); + --md-sys-color-primary-fixed: rgb(214 227 255); + --md-sys-color-on-primary-fixed: rgb(0 27 62); + --md-sys-color-primary-fixed-dim: rgb(170 199 255); + --md-sys-color-on-primary-fixed-variant: rgb(40 71 119); + --md-sys-color-secondary-fixed: rgb(218 226 249); + --md-sys-color-on-secondary-fixed: rgb(19 28 43); + --md-sys-color-secondary-fixed-dim: rgb(190 198 220); + --md-sys-color-on-secondary-fixed-variant: rgb(62 71 89); + --md-sys-color-tertiary-fixed: rgb(250 216 253); + --md-sys-color-on-tertiary-fixed: rgb(40 19 46); + --md-sys-color-tertiary-fixed-dim: rgb(221 188 224); + --md-sys-color-on-tertiary-fixed-variant: rgb(87 62 92); + --md-sys-color-surface-dim: rgb(217 217 224); + --md-sys-color-surface-bright: rgb(249 249 255); + --md-sys-color-surface-container-lowest: rgb(255 255 255); + --md-sys-color-surface-container-low: rgb(243 243 250); + --md-sys-color-surface-container: rgb(237 237 244); + --md-sys-color-surface-container-high: rgb(231 232 238); + --md-sys-color-surface-container-highest: rgb(226 226 233); +} \ No newline at end of file diff --git a/web_app/static/css/style.css b/web_app/static/css/style.css index d8885f1..fbc56fa 100644 --- a/web_app/static/css/style.css +++ b/web_app/static/css/style.css @@ -1,44 +1,48 @@ +/* ═══════════════════════════════════════════════════════════════════════════ + MATERIAL 3 EXPRESSIVE — REDESIGN v2 (Compatibility Enhanced) + ═══════════════════════════════════════════════════════════════════════════ */ + :root { - /* MD3 Color Tokens - Dark Theme (Violet/Deep Space) */ - --md-sys-color-background: #09090b; - --md-sys-color-on-background: #e2e2e6; + /* ══════════ Shape System (Expanded) ══════════ */ + --shape-corner-none: 0px; + --shape-corner-xs: 4px; + --shape-corner-sm: 8px; + --shape-corner-md: 12px; + --shape-corner-lg: 16px; + --shape-corner-xl: 28px; + --shape-corner-2xl: 32px; + --shape-corner-3xl: 48px; + --shape-corner-full: 9999px; - --md-sys-color-surface: #0f1115; - /* Main card bg */ - --md-sys-color-surface-dim: #09090b; - /* App bg */ - --md-sys-color-surface-container: #1b1f26; - /* Elevated cards */ - --md-sys-color-surface-container-high: #242933; - /* Modal/Nav */ + /* ══════════ Motion System (Springs) ══════════ */ + --motion-spring-fast: linear(0, 0.53 14.8%, 0.82 27%, 0.94 38.6%, 0.985 49.3%, 1.001 59.8%, 1); + --motion-spring-normal: linear(0, 0.45 13%, 0.76 27%, 0.92 41%, 0.98 56%, 1); + --motion-spring-slow: linear(0, 0.3 12%, 0.6 28%, 0.85 46%, 0.96 64%, 1); - --md-sys-color-primary: #d0bcff; - --md-sys-color-on-primary: #381e72; - --md-sys-color-primary-container: #4f378b; - --md-sys-color-on-primary-container: #eaddff; + --transition-standard: all 300ms var(--motion-spring-normal); - --md-sys-color-secondary: #ccc2dc; - --md-sys-color-on-secondary: #332d41; - --md-sys-color-secondary-container: #4a4458; - --md-sys-color-on-secondary-container: #e8def8; + /* ══════════ Typography (Outfit) ══════════ */ + --font-brand: 'Outfit', sans-serif; + --font-plain: 'Outfit', system-ui, sans-serif; - --md-sys-color-tertiary: #efb8c8; - --md-sys-color-outline: #938f99; - --md-sys-color-outline-variant: #49454f; + --type-display-large: 700 57px/64px var(--font-brand); + --type-display-medium: 700 45px/52px var(--font-brand); + --type-headline-large: 600 32px/40px var(--font-brand); + --type-headline-medium: 600 28px/36px var(--font-brand); + --type-title-large: 500 22px/28px var(--font-brand); + --type-title-medium: 500 16px/24px var(--font-brand); + --type-body-large: 400 16px/24px var(--font-plain); + --type-body-medium: 400 14px/20px var(--font-plain); + --type-label-large: 600 14px/20px var(--font-plain); - --radius-l: 24px; - --radius-pill: 50px; - --font-main: 'Outfit', sans-serif; - - /* Effects */ - --elevation-1: 0px 1px 3px 1px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.3); - --elevation-2: 0px 2px 6px 2px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.3); - --glass-bg: rgba(30, 33, 40, 0.7); - --glass-border: rgba(255, 255, 255, 0.08); - /* Faint stroke for premium feel */ + /* ══════════ Layout ══════════ */ + --layout-rail-width: 80px; } -* { +/* ══════════ Reset & Base ══════════ */ +*, +*::before, +*::after { box-sizing: border-box; margin: 0; padding: 0; @@ -48,140 +52,257 @@ body { background-color: var(--md-sys-color-background); color: var(--md-sys-color-on-background); - font-family: var(--font-main); + font-family: var(--font-plain); font-size: 16px; + line-height: 1.5; min-height: 100vh; - padding-bottom: 80px; - /* Space for bottom nav */ + display: flex; + overflow-x: hidden; + transition: background-color 0.4s var(--motion-spring-normal); } -/* Background Animation */ +/* ══════════ Backgrounds ══════════ */ #stars-container { position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: -2; - background: radial-gradient(circle at 50% 100%, #17101f 0%, #09090b 100%); - opacity: 0.6; + inset: 0; + z-index: -1; + pointer-events: none; + opacity: 0.4; } .glow-overlay { position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; + inset: 0; z-index: -1; - background: radial-gradient(circle at 80% 10%, rgba(208, 188, 255, 0.05) 0%, transparent 50%), - radial-gradient(circle at 20% 90%, rgba(79, 55, 139, 0.1) 0%, transparent 50%); pointer-events: none; + background: radial-gradient(circle at 50% 0%, color-mix(in srgb, var(--md-sys-color-primary) 15%, transparent), transparent 70%); } -/* Typography */ -h1 { - font-size: 32px; - font-weight: 600; - color: var(--md-sys-color-on-background); - letter-spacing: -0.5px; - margin-bottom: 4px; -} - -h2 { - font-size: 20px; - font-weight: 500; - color: var(--md-sys-color-on-background); -} - -.subtitle { - color: var(--md-sys-color-outline); - font-size: 14px; - font-weight: 400; -} - -/* Layout */ -.app-layout { - margin: 0 auto; - min-height: 100vh; -} - -.sidebar { - display: none; - /* Hidden on mobile */ - position: sticky; - top: 0; +/* ══════════ App Structure ══════════ */ +.app-shell { + display: flex; + width: 100%; + max-width: 100vw; height: 100vh; - padding: 30px; - flex-direction: column; - z-index: 50; + overflow: hidden; } -.content-area { - flex: 1; - padding: 0; +.nav-rail { + width: var(--layout-rail-width); + height: 100%; display: flex; flex-direction: column; - min-width: 0; - /* Prevent flex overflow */ -} - -/* Components: Glass / Surface */ -.glass { - background: var(--md-sys-color-surface-container); - border: 1px solid var(--glass-border); - border-radius: var(--radius-l); - backdrop-filter: blur(10px); - /* Less blur for performance, color does work */ -} - -/* Headers */ -.mobile-header { - height: 64px; - padding: 0 24px; - display: flex; align-items: center; - justify-content: space-between; - position: sticky; - top: 0; - z-index: 50; - background: rgba(9, 9, 11, 0.8); - backdrop-filter: blur(12px); - border-bottom: 1px solid var(--glass-border); + padding: 32px 0; + background: var(--md-sys-color-surface); + border-right: 1px solid var(--md-sys-color-outline-variant); + z-index: 20; + flex-shrink: 0; } -.logo-mini { - font-size: 22px; +.nav-rail .logo-area { + margin-bottom: 40px; + color: var(--md-sys-color-primary); + transition: transform 0.3s var(--motion-spring-normal); +} + +.nav-rail .logo-area:hover { + transform: scale(1.1) rotate(5deg); +} + +.nav-rail .nav-destinations { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + align-items: center; +} + +.rail-item { + width: 56px; + height: 56px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + border-radius: 16px; + color: var(--md-sys-color-on-surface-variant); + cursor: pointer; + transition: var(--transition-standard); + position: relative; + border: none; + background: transparent; +} + +.rail-item::before { + content: ''; + position: absolute; + inset: 0; + background: var(--md-sys-color-secondary-container); + border-radius: 16px; + transform: scaleY(0); + opacity: 0; + transition: var(--transition-standard); + z-index: -1; +} + +.rail-item.active { + color: var(--md-sys-color-on-secondary-container); +} + +.rail-item.active::before { + transform: scaleY(1); + opacity: 1; +} + +.rail-item:hover:not(.active) { + background: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent); +} + +.rail-item i { + width: 24px; + height: 24px; +} + +.rail-item span { + font-size: 10px; font-weight: 600; - display: flex; - align-items: center; - gap: 8px; - letter-spacing: -0.5px; + letter-spacing: 0.5px; } -.user-chip .avatar-xs { - width: 36px; - height: 36px; - border-radius: 50%; +.rail-fab { + margin-top: auto; + width: 56px; + height: 56px; + border-radius: 16px; background: var(--md-sys-color-primary-container); color: var(--md-sys-color-on-primary-container); display: flex; align-items: center; justify-content: center; - font-weight: 600; - font-size: 14px; + cursor: pointer; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); + border: none; + transition: var(--transition-standard); } -/* View Container */ -.view-container { - padding: 24px; - animation: fadeUp 0.3s cubic-bezier(0.2, 0.0, 0, 1.0); +.rail-fab:hover { + transform: rotate(90deg) scale(1.05); } -@keyframes fadeUp { +/* Main Pane */ +.main-pane { + flex: 1; + height: 100%; + overflow-y: auto; + background: var(--md-sys-color-surface-container); + border-top-left-radius: var(--shape-corner-2xl); + border-bottom-left-radius: var(--shape-corner-2xl); + margin: 0; + position: relative; + display: flex; + flex-direction: column; + padding-bottom: 20px; +} + +/* Mobile Header/Nav */ +.mobile-header, +.bottom-bar { + display: none; +} + +@media (max-width: 800px) { + .nav-rail { + display: none; + } + + .main-pane { + border-radius: 0; + background: var(--md-sys-color-background); + } + + .mobile-header { + display: flex; + height: 64px; + min-height: 64px; + max-height: 64px; + align-items: center; + justify-content: space-between; + padding: 0 20px; + position: sticky; + top: 0; + z-index: 50; + background: color-mix(in srgb, var(--md-sys-color-background) 80%, transparent); + backdrop-filter: blur(16px); + border-bottom: 1px solid color-mix(in srgb, var(--md-sys-color-outline-variant) 50%, transparent); + flex-shrink: 0; + } + + .mobile-header #header-title { + font-size: 18px; + font-weight: 600; + line-height: 1.2; + white-space: nowrap; + } + + .bottom-bar { + display: flex; + height: 80px; + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: var(--md-sys-color-surface-container-high); + z-index: 50; + justify-content: space-around; + align-items: center; + padding-bottom: 12px; + border-top-left-radius: var(--shape-corner-xl); + border-top-right-radius: var(--shape-corner-xl); + box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.05); + } + + .bar-item { + flex: 1; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + color: var(--md-sys-color-on-surface-variant); + background: none; + border: none; + transition: color 0.2s; + cursor: pointer; + } + + .bar-pill { + padding: 4px 20px; + border-radius: 16px; + transition: var(--transition-standard); + } + + .bar-item.active .bar-pill { + background: var(--md-sys-color-secondary-container); + color: var(--md-sys-color-on-secondary-container); + } +} + +/* ══════════ Content Layout ══════════ */ +.view-content { + padding: 24px 20px; + width: 100%; + max-width: 800px; + margin: 0 auto; + animation: slideUp 0.5s var(--motion-spring-normal); +} + +@keyframes slideUp { from { opacity: 0; - transform: translateY(15px); + transform: translateY(40px); } to { @@ -190,455 +311,1077 @@ h2 { } } -/* MD3 Cards */ -.status-card { - background: var(--md-sys-color-surface-container); +.page-header-large { + margin-bottom: 28px; + padding-top: 16px; + padding-bottom: 8px; + min-height: 80px; +} + +.page-header-large h1 { + font: var(--type-headline-large); + color: var(--md-sys-color-on-surface); + margin: 0 0 8px 0; + font-size: 32px; + font-weight: 700; + line-height: 1.2; +} + +.subtitle { + font: var(--type-body-medium); + color: var(--md-sys-color-outline); + margin: 0; + font-size: 15px; +} + +/* ══════════ COMPATIBILITY LAYER (app.js Support) ══════════ */ + +/* --- 1. Generic Utils --- */ +.loading-spinner { + border: 4px solid var(--md-sys-color-surface-container-highest); + border-top: 4px solid var(--md-sys-color-primary); + border-radius: 50%; + width: 40px; + height: 40px; + animation: spin 1s linear infinite; + margin: 40px auto; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +/* --- 2. Cards & Plans --- */ +/* app.js: class="glass plan-card plan-item" */ +.plan-card, +.m3-card, +.card { + background: var(--md-sys-color-surface-container-high); + border-radius: var(--shape-corner-xl); padding: 24px; - border-radius: var(--radius-l); margin-bottom: 16px; position: relative; overflow: hidden; - border: 1px solid var(--glass-border); - box-shadow: var(--elevation-1); + transition: var(--transition-standard); + border: 1px solid transparent; } -/* Stats Ring */ -.status-ring { - width: 120px; - height: 120px; - margin-bottom: 16px; - align-self: center; -} - -.circular-chart { - display: block; - width: 100%; -} - -.circle-bg { - stroke: var(--md-sys-color-surface-container-high); - stroke-width: 8; - /* Thicker premium look */ - fill: none; -} - -.circle { - stroke: var(--md-sys-color-primary); - stroke-width: 8; - stroke-linecap: round; - fill: none; - transition: stroke-dasharray 1s ease; -} - -.ring-content { - position: absolute; - top: 24px; - left: 24px; - width: 120px; - height: 120px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; -} - -.ring-content .value { - font-size: 24px; - font-weight: 700; - color: var(--md-sys-color-primary); -} - -.ring-content .unit { - font-size: 12px; - color: var(--md-sys-color-outline); -} - -/* Details List */ -.status-details { - background: var(--md-sys-color-surface-dim); - border-radius: 16px; - padding: 16px; -} - -.detail-item { - display: flex; - justify-content: space-between; - padding: 12px 0; - border-bottom: 1px solid var(--md-sys-color-outline-variant); - font-size: 15px; -} - -.detail-item:last-child { - border: none; - padding-bottom: 0; -} - -.detail-item:first-child { - padding-top: 0; -} - -/* Quick Actions */ -.quick-actions { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; -} - -.action-card { - background: var(--md-sys-color-surface-container); - border: 1px solid var(--glass-border); - border-radius: var(--radius-l); - padding: 20px; - display: flex; - flex-direction: column; - align-items: center; - gap: 12px; - cursor: pointer; - transition: transform 0.1s; - user-select: none; -} - -.action-card:active { - transform: scale(0.98); - background: var(--md-sys-color-surface-container-high); -} - -.action-card span { - font-weight: 500; - font-size: 15px; -} - -.icon-circle { - width: 48px; - height: 48px; - border-radius: 50%; - background: var(--md-sys-color-secondary-container); - color: var(--md-sys-color-on-secondary-container); - display: flex; - align-items: center; - justify-content: center; -} - -.icon-circle.pop { - background: var(--md-sys-color-primary-container); - color: var(--md-sys-color-on-primary-container); -} - -/* Shop Items */ -.plans-list { - display: flex; - flex-direction: column; - gap: 12px; -} - -.plan-card { - background: var(--md-sys-color-surface-container); - border-radius: var(--radius-l); - padding: 20px; - border: 1px solid var(--glass-border); +.plan-card:hover { + /* Hover unavailable on mobile touch */ + transform: translateY(-2px); + box-shadow: 0 8px 24px -6px rgba(0, 0, 0, 0.15); + background: var(--md-sys-color-surface-container-highest); } .plan-header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 8px; + margin-bottom: 20px; + min-height: 32px; } .plan-title { - font-size: 18px; - font-weight: 600; + font: var(--type-headline-small); color: var(--md-sys-color-on-surface); } .plan-price { - font-size: 20px; - font-weight: 700; + font: var(--type-title-large); color: var(--md-sys-color-primary); + font-weight: 700; } .plan-specs { display: flex; - gap: 16px; - margin-bottom: 16px; - color: var(--md-sys-color-outline); - font-size: 14px; + gap: 12px; + margin-bottom: 24px; + flex-wrap: wrap; } -/* Buttons */ -.btn-primary { - width: 100%; - height: 48px; - border-radius: var(--radius-pill); - border: none; - background: var(--md-sys-color-primary); - color: var(--md-sys-color-on-primary); - font-size: 14px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - cursor: pointer; - box-shadow: var(--elevation-1); - transition: box-shadow 0.2s; -} - -.btn-primary:active { - box-shadow: none; -} - -.btn-secondary { - width: 100%; - height: 48px; - border-radius: var(--radius-pill); - border: 1px solid var(--md-sys-color-outline); - background: transparent; - color: var(--md-sys-color-primary); - font-size: 14px; - font-weight: 600; - cursor: pointer; -} - -.btn-small { - height: 40px; - padding: 0 20px; - border-radius: var(--radius-pill); - border: none; - background: var(--md-sys-color-primary-container); - color: var(--md-sys-color-on-primary-container); - font-weight: 600; - cursor: pointer; -} - -/* MD3 Bottom Navigation */ -.bottom-nav { - position: fixed; - bottom: 0; - left: 0; - width: 100%; - height: 80px; - background: var(--md-sys-color-surface-container); +.plan-specs span { display: flex; - justify-content: space-around; align-items: center; - border-top: 1px solid var(--glass-border); - z-index: 100; - padding-bottom: 10px; - /* Safe area */ + gap: 8px; + padding: 10px 16px; + background: var(--md-sys-color-surface-container); + border-radius: 12px; + font: var(--type-label-large); + color: var(--md-sys-color-on-surface-variant); + min-height: 44px; } -.nav-item { - background: none; - border: none; +.plan-specs span i, +.plan-specs span svg { + width: 18px; + height: 18px; + flex-shrink: 0; + color: var(--md-sys-color-primary); +} + +/* Plan card buttons */ +.plan-card .btn-primary { + height: 52px; + font-size: 15px; + font-weight: 600; + margin-top: 8px; +} + +/* --- 3. Lists (Admin, Download, Profile) --- */ +/* app.js: class="list-item glass" */ +.list-item, +.shop-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + background: var(--md-sys-color-surface-container-low); + border-radius: var(--shape-corner-lg); + margin-bottom: 8px; + color: var(--md-sys-color-on-surface); + cursor: pointer; + transition: background 0.2s; + user-select: none; +} + +.list-item:active { + background: var(--md-sys-color-surface-container-highest); +} + +/* Prevent text overflow in lists */ +.list-item>div { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Admin Specific */ +.admin-list { display: flex; flex-direction: column; - align-items: center; gap: 4px; - width: 64px; - cursor: pointer; - color: var(--md-sys-color-outline); } -.nav-item svg { - z-index: 2; - transition: all 0.2s; -} - -/* Active Indicator (Pill) */ -.nav-item.active { - color: var(--md-sys-color-on-secondary-container); -} - -.nav-item.active svg { - background: var(--md-sys-color-secondary-container); - border-radius: 16px; - padding: 4px 20px; - width: 64px; - box-sizing: content-box; - /* Stretch pill */ - height: 24px; -} - -/* Center FAB Override for 'Config' */ -.nav-item.center-fab { - transform: translateY(-20px); -} - -.nav-item.center-fab .fab-bg { - width: 56px; - height: 56px; - background: var(--md-sys-color-primary-container); - color: var(--md-sys-color-on-primary-container); - border-radius: 16px; - /* Squircle */ +.search-box { display: flex; + gap: 12px; + margin-bottom: 16px; align-items: center; - justify-content: center; - box-shadow: var(--elevation-2); } -/* Inputs */ -.glass-input { +.search-box .glass-input { + flex: 1; + height: 48px; + border-radius: 24px; + border: none; + border-bottom: none; + padding: 0 20px; +} + +.search-box .btn-primary { + height: 48px; + width: auto; + min-width: 100px; + padding: 0 24px; + border-radius: 24px; + flex-shrink: 0; +} + +/* --- 4. Inputs --- */ +/* app.js: class="glass-input" */ +.glass-input, +textarea.glass-input { width: 100%; - height: 56px; - border: 1px solid var(--md-sys-color-outline); - border-radius: 8px; - /* M3 text field */ - background: var(--md-sys-color-surface); - color: white; - padding: 0 16px; - font-size: 16px; -} - -.glass-input:focus { - border-color: var(--md-sys-color-primary); - border-width: 2px; + background: var(--md-sys-color-surface-container-highest); + border: none; + border-bottom: 2px solid var(--md-sys-color-outline); + border-radius: 4px 4px 0 0; + padding: 16px; + font: var(--type-body-large); + color: var(--md-sys-color-on-surface); + transition: all 0.2s; outline: none; } -/* Profile */ -.profile-main { - align-items: center; - text-align: center; - display: flex; - flex-direction: column; - gap: 8px; - background: transparent; - border: none; - box-shadow: none; +.glass-input:focus { + background: var(--md-sys-color-surface-container-high); + border-bottom-color: var(--md-sys-color-primary); } -.big-avatar { - width: 96px; - height: 96px; - background: var(--md-sys-color-primary-container); - color: var(--md-sys-color-on-primary-container); - border-radius: 50%; +textarea.glass-input { + border-radius: 12px; + border: 1px solid var(--md-sys-color-outline-variant); +} + +textarea.glass-input:focus { + border-color: var(--md-sys-color-primary); +} + +/* --- 5. Buttons --- */ +/* app.js: class="btn-primary", "btn-secondary" */ +button { + font-family: inherit; +} + +.btn-primary, +.btn-secondary, +.btn-error { + width: 100%; + height: 48px; display: flex; align-items: center; justify-content: center; - font-size: 40px; - font-weight: 700; -} - -.id-badge { - background: var(--md-sys-color-surface-container-high); - padding: 6px 12px; - border-radius: 8px; - font-family: monospace; - font-size: 12px; -} - -/* List Menu */ -.list-menu { - border-radius: var(--radius-l); - background: var(--md-sys-color-surface-container); - border: 1px solid var(--md-sys-color-outline-variant); - overflow: hidden; -} - -.list-item { - height: 56px; - display: flex; - align-items: center; - padding: 0 16px; - gap: 16px; - border-bottom: 1px solid var(--md-sys-color-outline-variant); - color: var(--md-sys-color-on-surface); - background: none; - border: none; - width: 100%; + gap: 8px; + border-radius: 24px; + /* Full pill */ + font: var(--type-label-large); + letter-spacing: 0.5px; + cursor: pointer; + transition: all 0.2s var(--motion-spring-fast); position: relative; overflow: hidden; } -.list-item:hover { - background: rgba(255, 255, 255, 0.05); +.btn-primary { + background: var(--md-sys-color-primary); + color: var(--md-sys-color-on-primary); + border: none; } -/* Material Ripple Effect */ -.ripple { - position: absolute; - border-radius: 50%; - transform: scale(0); - animation: ripple 0.6s linear; - background-color: rgba(255, 255, 255, 0.3); - pointer-events: none; +.btn-primary:active { + transform: scale(0.98); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--md-sys-color-primary) 30%, transparent); } -@keyframes ripple { +.btn-primary:disabled { + opacity: 0.5; + background: var(--md-sys-color-on-surface); + cursor: not-allowed; +} + +.btn-secondary { + background: transparent; + color: var(--md-sys-color-primary); + border: 1px solid var(--md-sys-color-outline); +} + +.btn-secondary:active { + background: color-mix(in srgb, var(--md-sys-color-primary) 10%, transparent); +} + +.btn-error { + background: var(--md-sys-color-error-container); + color: var(--md-sys-color-on-error-container); + border: none; +} + +/* --- 6. Stats & Rings (Dashboard) --- */ +.status-overview { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.ring-card { + display: flex; + flex-direction: row; + /* Force row */ + align-items: center; + justify-content: flex-start; + gap: 24px; +} + +.ring-container { + width: 80px; + height: 80px; + position: relative; + flex-shrink: 0; +} + +.circular-chart { + width: 100%; + height: 100%; +} + +.circle-bg { + stroke: var(--md-sys-color-surface-container-highest); + stroke-width: 6; + fill: none; +} + +.circle { + stroke: var(--md-sys-color-primary); + stroke-width: 6; + stroke-linecap: round; + fill: none; + transition: stroke-dasharray 1s var(--motion-spring-slow); +} + +/* Actions Grid (Dashboard) */ +.actions-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-top: 20px; +} + +.action-btn-large { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + background: var(--md-sys-color-surface-container); + color: var(--md-sys-color-on-surface); + border: 1px solid transparent; + border-radius: 20px; + padding: 20px; + cursor: pointer; +} + +.action-btn-large:active { + background: var(--md-sys-color-secondary-container); + color: var(--md-sys-color-on-secondary-container); +} + +.action-btn-large i { + width: 28px; + height: 28px; +} + +/* --- 7. Modals & Confirms --- */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); + z-index: 200; + display: flex; + align-items: center; + /* Center vertically */ + justify-content: center; + padding: 20px; + animation: fadeOverlay 0.3s; +} + +.modal-overlay.hidden { + display: none; +} + +@keyframes fadeOverlay { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.modal-dialog, +.modal { + /* .modal for legacy */ + background: var(--md-sys-color-surface-container-high); + border-radius: 28px; + padding: 24px; + width: 100%; + max-width: 400px; + box-shadow: 0 20px 40px -10px rgba(0, 0, 0, 0.3); + animation: zoomModal 0.4s var(--motion-spring-fast); +} + +@keyframes zoomModal { + from { + transform: scale(0.8) translateY(20px); + opacity: 0; + } + + to { + transform: scale(1) translateY(0); + opacity: 1; + } +} + +.btn-tonal { + /* Admin tabs */ + background: var(--md-sys-color-secondary-container); + color: var(--md-sys-color-on-secondary-container); + padding: 8px 16px; + border-radius: 12px; + border: none; + font-weight: 600; + opacity: 0.6; + cursor: pointer; +} + +.btn-tonal.active { + opacity: 1; + background: var(--md-sys-color-primary); + color: var(--md-sys-color-on-primary); +} + +/* Subscription Buttons (Legacy) */ +.sub-actions { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 24px; +} + +/* Toast */ +.toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + background: var(--md-sys-color-inverse-surface); + color: var(--md-sys-color-inverse-on-surface); + padding: 14px 24px; + border-radius: 12px; + display: flex; + align-items: center; + gap: 12px; + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2); + z-index: 300; + min-width: 300px; + animation: toastUp 0.4s var(--motion-spring-normal); +} + +.toast i { + width: 20px; + height: 20px; +} + +.toast.success i { + color: #4ade80; +} + +.toast.error i { + color: #f87171; +} + +@keyframes toastUp { + from { + opacity: 0; + transform: translate(-50%, 20px); + } + + to { + opacity: 1; + transform: translate(-50%, 0); + } +} + +/* ══════════ Utility Classes ══════════ */ +.hidden { + display: none !important; +} + +/* ══════════ Accordion ══════════ */ +.accordion { + padding: 0; +} + +.acc-item { + border-bottom: 1px solid var(--md-sys-color-outline-variant); +} + +.acc-item:last-child { + border-bottom: none; +} + +.acc-head { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + cursor: pointer; + font: var(--type-label-large); + color: var(--md-sys-color-on-surface); + transition: background 0.2s; +} + +.acc-head:hover { + background: color-mix(in srgb, var(--md-sys-color-on-surface) 5%, transparent); +} + +.acc-head i { + width: 20px; + height: 20px; + transition: transform 0.3s var(--motion-spring-normal); +} + +.acc-item.open .acc-head i { + transform: rotate(180deg); +} + +.acc-body { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s var(--motion-spring-normal), padding 0.3s; + padding: 0 20px; +} + +.acc-item.open .acc-body { + max-height: 200px; + padding: 0 20px 16px; +} + +.acc-body p { + font: var(--type-body-medium); + color: var(--md-sys-color-outline); + margin-bottom: 8px; +} + +.acc-body p:last-child { + margin-bottom: 0; +} + +/* ══════════ Plan Item Badge ══════════ */ +.plan-badge { + position: absolute; + top: -8px; + right: 16px; + background: var(--md-sys-color-tertiary); + color: var(--md-sys-color-on-tertiary); + padding: 4px 12px; + border-radius: 12px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* ══════════ Headline Type Fix ══════════ */ +.page-header-large h1 { + font: var(--type-headline-large); +} + +/* ══════════ Type Additions (Missing) ══════════ */ +:root { + --type-display-small: 600 36px/44px var(--font-brand); + --type-headline-small: 500 24px/32px var(--font-brand); +} + +/* ══════════ Avatar Styles ══════════ */ +.avatar-xs { + width: 40px; + height: 40px; + min-width: 40px; + min-height: 40px; + max-width: 40px; + max-height: 40px; + flex-shrink: 0; + border-radius: 50%; + overflow: hidden; + background: var(--md-sys-color-primary-container); + color: var(--md-sys-color-on-primary-container); + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 16px; +} + +.avatar-xs img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; +} + +/* User chip avatar */ +.user-chip .avatar-xs { + width: 32px; + height: 32px; + min-width: 32px; + min-height: 32px; + max-width: 32px; + max-height: 32px; + font-size: 14px; +} + +/* Profile page big avatar */ +#profile-avatar { + width: 80px !important; + height: 80px !important; + min-width: 80px !important; + min-height: 80px !important; + max-width: 80px !important; + max-height: 80px !important; + font-size: 32px !important; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +/* Admin user detail avatar */ +#adm-user-avatar { + width: 48px !important; + height: 48px !important; + min-width: 48px !important; + min-height: 48px !important; + max-width: 48px !important; + max-height: 48px !important; + font-size: 20px !important; +} + +.big-avatar { + width: 88px; + height: 88px; + min-width: 88px; + min-height: 88px; + flex-shrink: 0; + border-radius: 50%; + background: var(--md-sys-color-primary-container); + color: var(--md-sys-color-on-primary-container); + display: flex; + align-items: center; + justify-content: center; + font-size: 36px; + font-weight: 700; + overflow: hidden; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.big-avatar img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; +} + +/* ══════════════════════════════════════════════════════════════════════════ + ENHANCED ANIMATIONS + ══════════════════════════════════════════════════════════════════════════ */ + +/* --- Button Press Effects --- */ +.btn-primary:active, +.btn-secondary:active, +.btn-error:active { + transform: scale(0.95); + transition: transform 100ms ease; +} + +.btn-primary:hover, +.btn-secondary:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15); +} + +/* --- Card Hover Effects --- */ +.plan-card, +.m3-card { + transition: transform 0.3s var(--motion-spring-normal), + box-shadow 0.3s var(--motion-spring-normal), + background 0.2s ease; +} + +.plan-card:hover, +.m3-card:hover { + transform: translateY(-4px); + box-shadow: 0 12px 32px -8px rgba(0, 0, 0, 0.2); +} + +.plan-card:active, +.m3-card:active { + transform: translateY(-1px) scale(0.99); +} + +/* --- Navigation Item Animations --- */ +.bar-item { + transition: transform 0.2s var(--motion-spring-fast), color 0.2s ease; +} + +.bar-item:active { + transform: scale(0.9); +} + +.bar-pill { + transition: transform 0.3s var(--motion-spring-normal), + background 0.2s ease; +} + +.bar-item:active .bar-pill { + transform: scale(0.85); +} + +.rail-item { + transition: transform 0.2s var(--motion-spring-fast), + background 0.2s ease, + color 0.2s ease; +} + +.rail-item:hover { + transform: scale(1.05); +} + +.rail-item:active { + transform: scale(0.95); +} + +/* --- List Item Animations --- */ +.list-item { + transition: transform 0.2s var(--motion-spring-fast), + background 0.2s ease, + box-shadow 0.2s ease; +} + +.list-item:hover { + transform: translateX(4px); + background: var(--md-sys-color-surface-container); +} + +.list-item:active { + transform: translateX(2px) scale(0.98); +} + +/* --- Action Button Large Animations --- */ +.action-btn-large { + transition: transform 0.3s var(--motion-spring-normal), + background 0.2s ease, + box-shadow 0.3s ease; +} + +.action-btn-large:hover { + transform: translateY(-6px); + box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.2); +} + +.action-btn-large:active { + transform: translateY(-2px) scale(0.97); +} + +.action-btn-large i { + transition: transform 0.3s var(--motion-spring-fast); +} + +.action-btn-large:hover i { + transform: scale(1.2); +} + +/* --- Modal Animations --- */ +.modal-overlay { + animation: fadeIn 0.25s ease-out forwards; + transition: opacity 0.25s ease; +} + +.modal-overlay.closing { + animation: fadeOut 0.25s ease-out forwards; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + to { - transform: scale(4); opacity: 0; } } -/* Desktop Sidebar fix */ -@media (min-width: 769px) { - .bottom-nav { - display: none; +.modal-dialog { + animation: slideInUp 0.35s var(--motion-spring-normal) forwards; +} + +.modal-dialog.closing { + animation: slideOutDown 0.25s ease-out forwards; +} + +@keyframes slideInUp { + from { + opacity: 0; + transform: translateY(40px) scale(0.9); } - .app-layout { - display: flex; - max-width: 1200px; + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes slideOutDown { + from { + opacity: 1; + transform: translateY(0) scale(1); } - .sidebar { - display: flex; - flex-direction: column; - /* Ensure vertical layout */ - background: var(--md-sys-color-surface-container); - border-right: 1px solid var(--glass-border); - width: 280px; - /* Wider standard drawer */ - padding: 24px; - gap: 12px; + to { + opacity: 0; + transform: translateY(30px) scale(0.95); + } +} + +/* --- Tab Transition Animations --- */ +.tab-exit { + animation: tabFadeOut 0.15s ease-out forwards; +} + +.tab-enter { + animation: tabFadeIn 0.35s var(--motion-spring-normal) forwards; +} + +@keyframes tabFadeOut { + from { + opacity: 1; + transform: translateX(0); } - .content-area { - padding-bottom: 0; + to { + opacity: 0; + transform: translateX(-20px); + } +} + +@keyframes tabFadeIn { + from { + opacity: 0; + transform: translateX(20px); } - .sidebar .nav-item { - flex-direction: row; - width: 100%; - height: 56px; - padding: 0 24px; - border-radius: 28px; - /* Full pill */ - gap: 12px; - font-size: 14px; - font-weight: 500; - letter-spacing: 0.1px; - justify-content: flex-start; + to { + opacity: 1; + transform: translateX(0); + } +} + +/* --- Admin Content Smooth --- */ +#admin-content { + transition: opacity 0.2s ease; +} + +/* --- Ring Pulse Animation --- */ +.ring-container { + animation: pulse 3s ease-in-out infinite; +} + +@keyframes pulse { + + 0%, + 100% { + transform: scale(1); } - .sidebar .nav-item:hover { - background: rgba(255, 255, 255, 0.03); + 50% { + transform: scale(1.02); + } +} + +/* --- Avatar Hover --- */ +.avatar-xs, +#profile-avatar { + transition: transform 0.3s var(--motion-spring-fast), + box-shadow 0.3s ease; +} + +.user-chip:hover .avatar-xs, +#profile-avatar:hover { + transform: scale(1.1); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2); +} + +/* --- Status Badge Pulse --- */ +.status-badge { + animation: badgePulse 2s ease-in-out infinite; +} + +@keyframes badgePulse { + + 0%, + 100% { + opacity: 1; } - .sidebar .nav-item.active { - background: var(--md-sys-color-secondary-container); - color: var(--md-sys-color-on-secondary-container); + 50% { + opacity: 0.7; + } +} + +/* --- Toast Slide In --- */ +.toast { + animation: toastSlide 0.4s var(--motion-spring-normal); +} + +@keyframes toastSlide { + from { + opacity: 0; + transform: translateX(-50%) translateY(20px) scale(0.9); } - .sidebar .nav-item svg, - .sidebar .nav-item.active svg { - background: transparent; - padding: 0; - width: 24px; - height: 24px; - box-sizing: border-box; - border-radius: 0; + to { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1); + } +} + +.toast-exit { + animation: toastExit 0.4s var(--motion-spring-normal) forwards; +} + +@keyframes toastExit { + from { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1); } - /* Hide FAB background on desktop sidebar if we reuse the same button */ - .sidebar .fab-bg { - background: transparent; - width: auto; - height: auto; - box-shadow: none; - display: contents; - /* Treat children as direct children of button */ + to { + opacity: 0; + transform: translateX(-50%) translateY(20px) scale(0.9); } +} + +/* --- Icon Bounce on Hover --- */ +.rail-fab:hover i, +.action-btn-large:hover i { + animation: iconBounce 0.5s var(--motion-spring-fast); +} + +@keyframes iconBounce { + 0% { + transform: scale(1); + } + + 30% { + transform: scale(1.3); + } + + 50% { + transform: scale(0.9); + } + + 70% { + transform: scale(1.1); + } + + 100% { + transform: scale(1); + } +} + +/* --- Page Transition --- */ +.view-content { + animation: pageEnter 0.45s var(--motion-spring-normal); +} + +@keyframes pageEnter { + from { + opacity: 0; + transform: translateY(24px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.page-exit { + animation: pageExit 0.2s ease-in forwards; +} + +@keyframes pageExit { + from { + opacity: 1; + transform: translateY(0); + } + + to { + opacity: 0; + transform: translateY(-20px); + } +} + +/* --- Stagger Animation for Plan Cards --- */ +.plan-card { + animation: cardFadeIn 0.4s var(--motion-spring-normal) backwards; +} + +.plan-card:nth-child(1) { + animation-delay: 0.05s; +} + +.plan-card:nth-child(2) { + animation-delay: 0.1s; +} + +.plan-card:nth-child(3) { + animation-delay: 0.15s; +} + +.plan-card:nth-child(4) { + animation-delay: 0.2s; +} + +.plan-card:nth-child(5) { + animation-delay: 0.25s; +} + +@keyframes cardFadeIn { + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +/* --- Loading Spinner Smooth --- */ +.loading-spinner { + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +/* --- Accordion Smooth Open --- */ +.acc-body { + transition: max-height 0.35s var(--motion-spring-normal), + padding 0.35s var(--motion-spring-normal), + opacity 0.25s ease; + opacity: 0; +} + +.acc-item.open .acc-body { + opacity: 1; +} + +.acc-head i { + transition: transform 0.35s var(--motion-spring-normal); +} + +/* --- QR Code Container Glow --- */ +#qrcode-container { + transition: box-shadow 0.3s ease, transform 0.3s var(--motion-spring-normal); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); +} + +#qrcode-container:hover { + transform: scale(1.02); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); } \ No newline at end of file diff --git a/web_app/static/index.html b/web_app/static/index.html index 0e08793..f7ff56f 100644 --- a/web_app/static/index.html +++ b/web_app/static/index.html @@ -4,232 +4,416 @@ - Comet VPN + Stellarisei VPN + + - + + + + + + + - + +
+
-
- - - -
-
-
- Comet + + + + + + +
+
+
+ + Stellarisei
-
-
U
+
+
+ U
-
+ +
- - +
+ + +
- + + + + - - - - - - - -
-
-
- How to connect on iOS? - -
-
-
    -
  1. Install V2Box from AppStore.
  2. -
  3. Copy the link above.
  4. -
  5. Open V2Box, it will detect the link.
  6. -
  7. Tap "Import" and Connect.
  8. -
+ + + - - + + + + + + + + + + + + + + diff --git a/web_app/static/js/app.js b/web_app/static/js/app.js index f504f85..1c8b944 100644 --- a/web_app/static/js/app.js +++ b/web_app/static/js/app.js @@ -1,18 +1,319 @@ +// Global State +const API_BASE = '/api'; +let currentState = { + user: null, + subUrl: "", + promoCode: null, + device: 'unknown', + statsInterval: null, + lang: 'en' +}; + +/* --- LOCALIZATION --- */ +const translations = { + en: { + nav_home: "Home", + nav_shop: "Shop", + nav_config: "Config", + nav_promo: "Promo", + nav_profile: "Profile", + nav_support: "Support", + nav_admin: "Admin", + + dash_welcome: "Welcome back", + dash_used: "Used Traffic", + dash_expiry: "Until Expiry", + dash_status: "Subscription Status", + btn_extend: "Extend", + btn_connect: "Connect", + + shop_title: "Select Plan", + shop_subtitle: "Upgrade your experience", + btn_purchase: "Purchase", + + sub_title: "Connect", + sub_subtitle: "Setup your device", + sub_link_label: "Subscription Link", + btn_copy: "Copy Link", + sub_instructions: "Detailed Instructions", + sub_instr_1: "1. Download app for your device.", + sub_instr_2: "2. Copy the link above.", + sub_instr_3: "3. Import from Clipboard.", + sub_instr_4: "4. Connect.", + btn_download: "Download", + + prof_joined: "Joined", + prof_spent: "Total Spent", + prof_purchases: "Purchases", + prof_app_info: "App Info", + btn_support: "Support", + btn_about: "About", + + adm_title: "Admin", + adm_stats: "Stats", + adm_users: "Users", + adm_promos: "Promos", + adm_broadcast: "Broadcast", + adm_bot_stats: "Bot Stats", + adm_total_users: "Total Users", + adm_active_subs: "Active Subs", + adm_revenue: "Revenue", + adm_server: "Server", + adm_marzban: "Marzban Users", + btn_search: "Search", + btn_back: "Back", + adm_status: "Status", + adm_expiry: "Expiry", + adm_traffic: "Traffic", + adm_btn_days: "+Days", + adm_btn_exp: "Set Exp", + adm_btn_limit: "Set GB", + adm_btn_plan: "Plan", + adm_btn_toggle: "Toggle", + adm_btn_reset: "Reset", + adm_btn_delete: "Delete Sub", + adm_create_promo: "+ Create Promo", + adm_send: "Send", + adm_broadcast_msg: "Send message to all users", + ph_message: "Message...", + ph_search: "Search..." + }, + ru: { + nav_home: "Главная", + nav_shop: "Магазин", + nav_config: "Подкл.", + nav_promo: "Промо", + nav_profile: "Профиль", + nav_support: "Поддержка", + nav_admin: "Админка", + + dash_welcome: "С возвращением", + dash_used: "Использовано", + dash_expiry: "До истечения", + dash_status: "Статус подписки", + btn_extend: "Продлить", + btn_connect: "Подключить", + + shop_title: "Выберите план", + shop_subtitle: "Улучшите свой опыт", + btn_purchase: "Купить", + + sub_title: "Подключение", + sub_subtitle: "Настройка устройства", + sub_link_label: "Ссылка подписки", + btn_copy: "Скопировать", + sub_instructions: "Инструкция", + sub_instr_1: "1. Скачайте приложение.", + sub_instr_2: "2. Скопируйте ссылку выше.", + sub_instr_3: "3. Импортируйте из буфера.", + sub_instr_4: "4. Подключитесь.", + btn_download: "Скачать", + + prof_joined: "Регистрация", + prof_spent: "Потрачено", + prof_purchases: "Покупок", + prof_app_info: "О приложении", + btn_support: "Поддержка", + btn_about: "О нас", + + adm_title: "Админка", + adm_stats: "Статистика", + adm_users: "Юзеры", + adm_promos: "Промо", + adm_broadcast: "Рассылка", + adm_bot_stats: "Статистика бота", + adm_total_users: "Всего юзеров", + adm_active_subs: "Активных", + adm_revenue: "Выручка", + adm_server: "Сервер", + adm_marzban: "В панели", + btn_search: "Поиск", + btn_back: "Назад", + adm_status: "Статус", + adm_expiry: "Истекает", + adm_traffic: "Трафик", + adm_btn_days: "+Дни", + adm_btn_exp: "Срок", + adm_btn_limit: "Лимит", + adm_btn_plan: "План", + adm_btn_toggle: "Вкл/Выкл", + adm_btn_reset: "Сброс", + adm_btn_delete: "Удалить", + adm_create_promo: "+ Промокод", + adm_send: "Отправить", + adm_broadcast_msg: "Сообщение всем юзерам", + ph_message: "Сообщение...", + ph_search: "Поиск..." + } +}; + +function t(key) { + const lang = currentState.lang || 'en'; + return translations[lang][key] || translations['en'][key] || key; +} + +function applyTranslations(container = document) { + container.querySelectorAll('[data-t]').forEach(el => { + el.textContent = t(el.dataset.t); + }); + container.querySelectorAll('[data-tp]').forEach(el => { + el.placeholder = t(el.dataset.tp); + }); +} + +// Telegram Init +const tg = window.Telegram?.WebApp; + +function initApp() { + // Enhanced OS Detection + const ua = navigator.userAgent.toLowerCase(); + if (/iphone|ipad|ipod/.test(ua)) currentState.device = 'ios'; + else if (/android/.test(ua)) currentState.device = 'android'; + else if (/macintosh|mac os x/.test(ua)) currentState.device = 'macos'; + else if (/windows/.test(ua)) currentState.device = 'windows'; + else if (/linux/.test(ua)) currentState.device = 'linux'; + else currentState.device = 'desktop'; + + if (tg) { + tg.ready(); + tg.expand(); + try { tg.setHeaderColor('#0f172a'); } catch (e) { } + + currentState.user = tg.initDataUnsafe?.user; + if (tg.initDataUnsafe?.user?.language_code === 'ru') { + currentState.lang = 'ru'; + } + + // Refine from TG platform + const p = tg.platform; + if (['ios'].includes(p)) currentState.device = 'ios'; + else if (['macos'].includes(p)) currentState.device = 'macos'; + else if (['android', 'android_x'].includes(p)) currentState.device = 'android'; + + // Theming + applyTheme(); + tg.onEvent('themeChanged', applyTheme); + } + + // Dev Mock + if (!currentState.user) { + currentState.user = { id: 583602906, first_name: 'Dev', username: 'developer', photo_url: null }; + } + + // Avatar Setup + updateAvatar(currentState.user); + + // Initial Route + router('dashboard'); + applyTranslations(); + + // Global Key Listeners + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeModal(); + }); +} + +function applyTheme() { + const body = document.body; + const root = document.documentElement; + + // Determine color scheme from Telegram or system preference + let colorScheme = 'dark'; // default + + if (tg) { + colorScheme = tg.colorScheme || 'dark'; + } else if (window.matchMedia) { + colorScheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + + // Apply theme class to body + body.classList.remove('dark', 'light'); + body.classList.add(colorScheme); + + // Additional TG theme customization + if (tg?.themeParams) { + const theme = tg.themeParams; + + // Override primary color if provided by Telegram + if (theme.button_color) { + root.style.setProperty('--md-sys-color-primary', theme.button_color); + if (theme.button_text_color) { + root.style.setProperty('--md-sys-color-on-primary', theme.button_text_color); + } + } + + // Set header color for native feel + try { + tg.setHeaderColor(colorScheme === 'dark' ? '#111318' : '#f9f9ff'); + } catch (e) { } + } + + console.log(`Theme applied: ${colorScheme}`); +} + +function updateAvatar(user) { + const headerAvatar = document.getElementById('header-avatar'); + if (!headerAvatar) return; + + if (user.photo_url) { + headerAvatar.innerHTML = `Avatar`; + } else { + headerAvatar.textContent = (user.first_name || 'U')[0].toUpperCase(); + } +} + // Navigation -function router(pageName) { +async function router(pageName) { const viewContainer = document.getElementById('app-view'); const template = document.getElementById(`view-${pageName}`); + // Don't route if already on page (optional optimization, but good for UX) + // const current = document.querySelector('.nav-item.active'); + // if (current && current.dataset.page === pageName) return; + + // Clear existing intervals + if (currentState.statsInterval) { + clearInterval(currentState.statsInterval); + currentState.statsInterval = null; + } + // Update Nav State document.querySelectorAll('.nav-item').forEach(item => { if (item.dataset.page === pageName) item.classList.add('active'); else item.classList.remove('active'); }); + // Animate Exit if content exists + if (viewContainer.innerHTML.trim() !== '') { + viewContainer.classList.add('page-exit'); + await new Promise(r => setTimeout(r, 200)); + viewContainer.classList.remove('page-exit'); + } + + // Update Header Title + const headerTitle = document.getElementById('header-title'); + if (headerTitle) { + const titles = { + 'dashboard': 'Dashboard', + 'shop': 'Shop', + 'subscription': 'Config', + 'profile': 'Profile', + 'support': 'Support', + 'admin': 'Admin Control' + }; + headerTitle.textContent = titles[pageName] || 'Comet'; + } + // Swap View if (template) { viewContainer.innerHTML = ''; viewContainer.appendChild(template.content.cloneNode(true)); + applyTranslations(viewContainer); + + // Retrigger enter animation + viewContainer.style.animation = 'none'; + viewContainer.offsetHeight; /* trigger reflow */ + viewContainer.style.animation = null; } // Init Page Logic @@ -20,43 +321,19 @@ function router(pageName) { if (pageName === 'shop') loadShop(); if (pageName === 'subscription') loadSubscription(); if (pageName === 'profile') loadProfile(); + if (pageName === 'admin') loadAdmin(); // Lucide Icons - if (window.lucide) lucide.createIcons(); + try { + if (window.lucide) lucide.createIcons(); + } catch (e) { + console.error("Lucide Error:", e); + } // Smooth Scroll Top window.scrollTo({ top: 0, behavior: 'smooth' }); } -// Global State -const API_BASE = '/api'; -let currentState = { - user: null, - subUrl: "" -}; - -// Telegram Init -const tg = window.Telegram?.WebApp; -if (tg) { - tg.ready(); - tg.expand(); - // Wrap in try-catch for header color as it might fail in some versions - try { tg.setHeaderColor('#0f172a'); } catch (e) { } - - currentState.user = tg.initDataUnsafe?.user; -} - -// Dev Mock -if (!currentState.user) { - currentState.user = { id: 123456789, first_name: 'Dev', username: 'developer' }; -} - -// Initial UI Setup -const headerAvatar = document.getElementById('header-avatar'); -if (headerAvatar) { - headerAvatar.textContent = (currentState.user.first_name || 'U')[0].toUpperCase(); -} - // ------ PAGE LOGIC ------ async function loadDashboard() { @@ -69,30 +346,89 @@ async function loadDashboard() { if (data.error) throw new Error(data.error); // Update Text - document.getElementById('dash-status').textContent = data.status; - document.getElementById('dash-limit').textContent = `${data.data_limit_gb} GB`; - document.getElementById('dash-expire').textContent = data.expire_date; - document.getElementById('dash-data-left').textContent = data.used_traffic_gb; + const statusEl = document.getElementById('dash-status'); + if (statusEl) statusEl.textContent = data.status; - // Progress Ring - const circle = document.getElementById('data-ring'); - if (circle) { - const limit = data.data_limit_gb || 100; // avoid div by zero - const used = data.used_traffic_gb || 0; - const percent = Math.min((used / limit) * 100, 100); + const limitEl = document.getElementById('dash-limit'); + if (limitEl) limitEl.textContent = `${data.data_limit_gb} GB`; - // stroke-dasharray="current, 100" (since pathLength=100 logic or simply percentage) - // standard dasharray for 36 viewbox is approx 100. - circle.setAttribute('stroke-dasharray', `${percent}, 100`); - circle.style.stroke = percent > 90 ? '#f87171' : '#6366f1'; + const expireEl = document.getElementById('dash-expire'); + if (expireEl) expireEl.textContent = data.expire_date; + + const leftEl = document.getElementById('dash-data-left'); + if (leftEl) leftEl.textContent = data.used_traffic_gb; + + const daysLeftEl = document.getElementById('dash-days-left'); + if (daysLeftEl) { + daysLeftEl.textContent = data.days_left > 10000 ? "∞" : data.days_left; + } + + // Progress Rings + // 1. Traffic Ring + const circle = document.getElementById('data-ring'); + if (circle) { + const limit = data.data_limit_gb || 0; + const used = data.used_traffic_gb || 0; + // Handle infinity (large limit) + let percent = 0; + if (limit > 900000) { + percent = 0; // Or some "full" state? 0 is safe for used/unlim + } else if (limit > 0) { + percent = Math.min((used / limit) * 100, 100); + } + circle.setAttribute('stroke-dasharray', `${percent}, 100`); + circle.style.stroke = percent > 90 ? '#f87171' : 'var(--md-sys-color-primary)'; + } + + // 2. Expiry Ring + const expCircle = document.getElementById('exp-ring'); + if (expCircle) { + const daysLeft = data.days_left || 0; + let expPercent = 0; + if (daysLeft > 10000) { + expPercent = 0; // Infinity looks like a full/empty ring depending on logic. Let's say 0 used. + } else { + // Assuming max display represents 30 days for visual context or 100% is safe + // Let's use 30 as a standard window for the ring animation if we don't know the plan + const standardWindow = 30; + expPercent = Math.max(0, 100 - (Math.min(daysLeft, standardWindow) / standardWindow * 100)); + } + expCircle.setAttribute('stroke-dasharray', `${expPercent}, 100`); + expCircle.style.stroke = expPercent > 80 ? '#f87171' : 'var(--md-sys-color-primary)'; } - // Save sub url globally currentState.subUrl = data.subscription_url; + currentState.user_full = data; + + // Admin visibility + const adminNav = document.getElementById('nav-admin'); + const mobileProfileBtn = document.getElementById('mobile-profile-btn'); + const mobileAdminBtn = document.getElementById('mobile-admin-btn'); + + if (data.is_admin) { + // Desktop rail + if (adminNav) adminNav.classList.remove('hidden'); + // Mobile: hide Profile, show Admin + if (mobileProfileBtn) mobileProfileBtn.classList.add('hidden'); + if (mobileAdminBtn) mobileAdminBtn.classList.remove('hidden'); + } else { + // Desktop rail + if (adminNav) adminNav.classList.add('hidden'); + // Mobile: show Profile, hide Admin + if (mobileProfileBtn) mobileProfileBtn.classList.remove('hidden'); + if (mobileAdminBtn) mobileAdminBtn.classList.add('hidden'); + } + + // Update user photo if not present or server provides it + if (data.photo_url) { + currentState.user.photo_url = data.photo_url; + updateAvatar(currentState.user); + } } catch (e) { console.error(e); - document.getElementById('dash-status').textContent = 'Error'; + const statusEl = document.getElementById('dash-status'); + if (statusEl) statusEl.textContent = 'Error'; } } @@ -107,21 +443,21 @@ async function loadShop() { container.innerHTML = ''; plans.forEach(plan => { const card = document.createElement('div'); - card.className = 'glass plan-card plan-item'; // plan-item for animation + card.className = 'glass plan-card plan-item'; card.innerHTML = `
${plan.name} ${plan.price} ⭐️
- ${plan.data_limit} GB + ${plan.data_limit > 0 ? plan.data_limit + ' GB' : 'Unlimited'} ${plan.days} Days
`; container.appendChild(card); }); - lucide.createIcons(); + try { if (window.lucide) lucide.createIcons(); } catch (e) { } } catch (e) { container.textContent = "Failed to load plans."; @@ -130,8 +466,6 @@ async function loadShop() { async function initPayment(planId) { if (!tg) return alert("Open in Telegram"); - - // Simple loader on button const btn = event.target; const oldText = btn.innerText; btn.innerText = 'Creating..'; @@ -144,7 +478,6 @@ async function initPayment(planId) { }; if (currentState.promoCode) { body.promo_code = currentState.promoCode; - // Reset after use or keep active? Bot usually resets. Let's keep it until refresh. } const res = await fetch(`${API_BASE}/create-invoice`, { @@ -157,15 +490,15 @@ async function initPayment(planId) { if (data.invoice_link) { tg.openInvoice(data.invoice_link, (status) => { if (status === 'paid') { - tg.showAlert('Successful Payment!'); + showToast('Successful Payment!', 'success'); router('dashboard'); } }); } else { - tg.showAlert('Error: ' + (data.error || 'Unknown')); + showToast('Error: ' + (data.error || 'Unknown'), 'error'); } } catch (e) { - tg.showAlert('Network error'); + showToast('Network error', 'error'); } finally { btn.innerText = oldText; btn.disabled = false; @@ -173,10 +506,7 @@ async function initPayment(planId) { } async function loadSubscription() { - const linkEl = document.getElementById('config-link'); - const fetchBtn = document.querySelector('.btn-secondary'); - - // If we don't have URL, try to fetch it again via user stats + // Check missing sub url if (!currentState.subUrl) { try { const res = await fetch(`${API_BASE}/user/${currentState.user.id}`); @@ -186,109 +516,736 @@ async function loadSubscription() { } const url = currentState.subUrl; + const linkEl = document.getElementById('config-link'); if (url) { - linkEl.textContent = url; - // Gen QR + if (linkEl) linkEl.textContent = url; const qrContainer = document.getElementById('qrcode-container'); - qrContainer.innerHTML = ''; - new QRCode(qrContainer, { - text: url, - width: 160, - height: 160, - colorDark: "#000000", - colorLight: "#ffffff", - correctLevel: QRCode.CorrectLevel.M - }); + if (qrContainer) { + qrContainer.innerHTML = ''; + new QRCode(qrContainer, { + text: url, width: 160, height: 160, + colorDark: "#000000", colorLight: "#ffffff", + correctLevel: QRCode.CorrectLevel.M + }); + } } else { - linkEl.textContent = "No active subscription found"; - document.getElementById('qrcode-container').innerHTML = '
'; + if (linkEl) linkEl.textContent = "No active subscription"; } + + // Dynamic Apps based on Device + const device = currentState.device; + const appsContainer = document.getElementById('device-apps-container'); + if (appsContainer) { + const osNames = { 'ios': 'iOS', 'android': 'Android', 'macos': 'macOS', 'windows': 'Windows', 'linux': 'Linux' }; + const myOS = osNames[device] || 'Desktop'; + + appsContainer.innerHTML = ` +
+ + +
+ `; + lucide.createIcons(); + } +} + +function openDownloadModal() { + const device = currentState.device; + let apps = []; + + if (device === 'ios') { + apps = [ + { name: 'V2Box', desc: 'Sleek & Fast', link: 'https://apps.apple.com/us/app/v2box-v2ray-client/id6446814690' }, + { name: 'Streisand', desc: 'Premium Choice', link: 'https://apps.apple.com/us/app/streisand/id6450534064' } + ]; + } else if (device === 'android') { + apps = [ + { name: 'v2rayNG', desc: 'Standard Client', link: 'https://play.google.com/store/apps/details?id=com.v2ray.ang' }, + { name: 'NekoBox', desc: 'Powerful & Modern', link: 'https://github.com/MatsuriDayo/NekoBoxForAndroid/releases' }, + { name: 'Hiddify', desc: 'Universal All-in-One', link: 'https://play.google.com/store/apps/details?id=app.hiddify.com' } + ]; + } else if (device === 'windows') { + apps = [ + { name: 'NekoRay', desc: 'Powerful & Open Source', link: 'https://github.com/MatsuriDayo/nekoray/releases' }, + { name: 'Hiddify Next', desc: 'Simple & Modern', link: 'https://github.com/hiddify/hiddify-next/releases' }, + { name: 'v2rayN', desc: 'Classic Client', link: 'https://github.com/2many986/v2rayN/releases' } + ]; + } else if (device === 'macos') { + apps = [ + { name: 'V2Box', desc: 'Native M1/M2 Support', link: 'https://apps.apple.com/us/app/v2box-v2ray-client/id6446814690' }, + { name: 'Hiddify Next', desc: 'Elegant Desktop Client', link: 'https://github.com/hiddify/hiddify-next/releases' } + ]; + } else { + // Linux or fallback + apps = [ + { name: 'NekoRay', desc: 'Best for Linux', link: 'https://github.com/MatsuriDayo/nekoray/releases' }, + { name: 'Hiddify Next', desc: 'AppImage Available', link: 'https://github.com/hiddify/hiddify-next/releases' } + ]; + } + + let html = ` +
+

Choose a client for your device:

+
+ `; + + apps.forEach(app => { + html += ` +
+
+
${app.name}
+
${app.desc}
+
+ +
+ `; + }); + + html += `
`; + + openModal("Download App", html); + lucide.createIcons(); } function copyConfig() { if (currentState.subUrl) { navigator.clipboard.writeText(currentState.subUrl); - if (tg) tg.showAlert("Link copied to clipboard!"); - else alert("Copied!"); + showToast("Link copied!", "success"); } else { - if (tg) tg.showAlert("No subscription to copy."); + showToast("No subscription found.", "error"); } } function toggleAcc(header) { - const body = header.nextElementSibling; - body.classList.toggle('open'); - const icon = header.querySelector('svg'); - // Simple rotation logic if needed, or just rely on CSS + const accItem = header.parentElement; + accItem.classList.toggle('open'); } async function loadProfile() { document.getElementById('profile-name').textContent = currentState.user.first_name; document.getElementById('profile-id').textContent = `ID: ${currentState.user.id}`; + // Profile Avatar const avatar = document.getElementById('profile-avatar'); - avatar.textContent = (currentState.user.first_name || 'U')[0].toUpperCase(); + if (currentState.user.photo_url) { + avatar.innerHTML = ``; + } else { + avatar.textContent = (currentState.user.first_name || 'U')[0].toUpperCase(); + } + + // Load Stats if available + if (currentState.user_full) { + const data = currentState.user_full; + const regEl = document.getElementById('stat-reg-date'); + if (regEl) regEl.textContent = data.reg_date || '...'; + + const spentEl = document.getElementById('stat-spent'); + if (spentEl) spentEl.textContent = data.total_spent || '0'; + + const payEl = document.getElementById('stat-payments'); + if (payEl) payEl.textContent = data.total_payments || '0'; + } } async function checkPromo() { const input = document.getElementById('promo-input'); - const resDiv = document.getElementById('promo-result'); const code = input.value.trim(); if (!code) return; - try { const res = await fetch(`${API_BASE}/check-promo`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) }); - if (res.ok) { const data = await res.json(); - resDiv.innerHTML = `
✅ ${data.description}. Apply at checkout!
`; - currentState.promoCode = data.code; // Save for checkout - tg.showAlert(`Promo valid! Discount: ${data.discount}%. Go to Shop to buy.`); + currentState.promoCode = data.code; + showToast(`Promo Applied! ${data.discount}% OFF.`, "success"); + closeModal(); // Optional: close modal on success } else { - resDiv.innerHTML = `
❌ Invalid Code
`; + showToast("Invalid Promo Code", "error"); } } catch (e) { - resDiv.textContent = "Error checking promo"; + showToast("Error checking promo", "error"); } } -function openHelp() { - // Simple alert or modal. Using routing for now logic would be better if we had a help page. - alert("Support: @hoshimach1"); +// ------ MODALS & SUPPORT ------ + +function openModal(title, contentHtml) { + const overlay = document.getElementById('modal-overlay'); + const dialog = overlay.querySelector('.modal-dialog'); + const mTitle = document.getElementById('modal-title'); + const mBody = document.getElementById('modal-body'); + + mTitle.textContent = title; + mBody.innerHTML = contentHtml; + + overlay.classList.remove('hidden', 'closing'); + dialog.classList.remove('closing'); + + // Trigger reflow for animation restart + overlay.offsetHeight; + + // Lucide in modal + try { if (window.lucide) lucide.createIcons(); } catch (e) { } } -// Material Ripple Init -document.addEventListener('click', function (e) { - const target = e.target.closest('button, .action-card, .plan-card'); // Select ripple targets - if (target) { - const circle = document.createElement('span'); - const diameter = Math.max(target.clientWidth, target.clientHeight); - const radius = diameter / 2; +function closeModal(e) { + if (e && e.target !== e.currentTarget && !e.target.classList.contains('close-btn')) return; - const rect = target.getBoundingClientRect(); + const overlay = document.getElementById('modal-overlay'); + const dialog = overlay.querySelector('.modal-dialog'); - circle.style.width = circle.style.height = `${diameter}px`; - circle.style.left = `${e.clientX - rect.left - radius}px`; - circle.style.top = `${e.clientY - rect.top - radius}px`; - circle.classList.add('ripple'); + // Add closing class for exit animation + overlay.classList.add('closing'); + dialog.classList.add('closing'); - // Remove existing ripples to be clean or append? Append allows rapid clicks. - const ripple = target.getElementsByClassName('ripple')[0]; - if (ripple) { - ripple.remove(); + // Wait for animation to complete before hiding + setTimeout(() => { + overlay.classList.add('hidden'); + overlay.classList.remove('closing'); + dialog.classList.remove('closing'); + }, 250); +} + +function openSupport() { + const html = ` +
+

+ Describe your issue. We will contact you via Telegram. +

+ + +
+ `; + openModal("Support", html); +} + +function openAbout() { + openModal("About", ` +
+ +

Stellarisei VPN v1.2

+

+ Premium V2Ray service.
Fast, Secure, Reliable. +

+

© 2026 Stellarisei

+
+ `); +} + +async function sendSupport() { + const msg = document.getElementById('support-msg').value; + if (!msg.trim()) return; + + // Show loading + const btn = event.target; + const oldText = btn.innerText; + btn.innerText = "Sending..."; + btn.disabled = true; + + try { + const res = await fetch(`${API_BASE}/support`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_id: currentState.user.id, + username: currentState.user.username || "Unknown", + message: msg + }) + }); + + if (res.ok) { + closeModal(); + showToast("Message sent! Admin will contact you.", "success"); + } else { + showToast("Failed to send.", "error"); + } + } catch (e) { + showToast("Error sending message.", "error"); + } finally { + if (btn) { + btn.innerText = oldText; + btn.disabled = false; } - - target.appendChild(circle); } -}); +} -// Start -document.addEventListener('DOMContentLoaded', () => { - router('dashboard'); -}); +// --- ADMIN LOGIC --- + +async function adminInit() { + // Hidden tab check already in loadDashboard +} + +async function loadAdmin() { + // Micro-delay to ensure DOM is ready after router swap + setTimeout(() => { + const adminContent = document.getElementById('admin-content'); + if (!adminContent) return; + adminTab('stats'); + }, 0); +} + +async function adminTab(tabName) { + // UI update + document.querySelectorAll('.tab-item').forEach(btn => { + btn.classList.toggle('active', btn.textContent.trim().toLowerCase() === tabName.toLowerCase()); + }); + + const container = document.getElementById('admin-content'); + const template = document.getElementById(`admin-${tabName}`); + if (!template) return; + + // Exit animation + container.classList.add('tab-exit'); + + // Wait for exit animation + await new Promise(resolve => setTimeout(resolve, 150)); + + container.innerHTML = template.innerHTML; + + // Entry animation + container.classList.remove('tab-exit'); + container.classList.add('tab-enter'); + + // Remove entry class after animation + setTimeout(() => container.classList.remove('tab-enter'), 350); + + lucide.createIcons(); + + // Clear any previous interval when switching tabs + if (currentState.statsInterval) { + clearInterval(currentState.statsInterval); + currentState.statsInterval = null; + } + + if (tabName === 'stats') { + refreshAdminStats(); + currentState.statsInterval = setInterval(refreshAdminStats, 1000); + } else if (tabName === 'users') { + adminSearchUsers(); + } else if (tabName === 'promos') { + loadAdminPromos(); + } +} + +async function refreshAdminStats() { + try { + const res = await fetch(`${API_BASE}/admin/stats?user_id=${currentState.user.id}`); + const data = await res.json(); + if (data.error) return; + + const elTotal = document.getElementById('adm-total-users'); + const elActive = document.getElementById('adm-active-subs'); + const elRevenue = document.getElementById('adm-revenue'); + const elCpu = document.getElementById('adm-cpu'); + const elRam = document.getElementById('adm-ram'); + const elMarz = document.getElementById('adm-active-marz'); + + if (elTotal) elTotal.textContent = data.bot.total; + if (elActive) elActive.textContent = data.bot.active; + if (elRevenue) elRevenue.textContent = `${data.bot.revenue} ⭐️`; + if (elCpu) elCpu.textContent = `${data.server.cpu}%`; + if (elRam) elRam.textContent = `${data.server.ram_used} / ${data.server.ram_total} GB`; + if (elMarz) elMarz.textContent = data.server.active_users; + } catch (e) { + console.error("Stats fetch error:", e); + } +} + +async function adminSearchUsers() { + const searchInput = document.getElementById('admin-user-search'); + if (searchInput && !searchInput.dataset.listener) { + searchInput.addEventListener('keyup', (e) => { + if (e.key === 'Enter') adminSearchUsers(); + }); + searchInput.dataset.listener = 'true'; + } + const query = searchInput ? searchInput.value : ''; + const res = await fetch(`${API_BASE}/admin/users?user_id=${currentState.user.id}&query=${query}`); + const users = await res.json(); + + const list = document.getElementById('admin-users-list'); + list.innerHTML = ''; + + if (users.length === 0) { + list.innerHTML = ` +
+ +

No users found

+
+ `; + lucide.createIcons(); + return; + } + + users.forEach(u => { + const div = document.createElement('div'); + div.className = 'list-item glass'; + div.style.marginBottom = '8px'; + div.style.padding = '8px 16px'; + div.innerHTML = ` +
+ +
+
+
${u.username || 'User'}
+
${u.user_id}
+
+ + `; + div.onclick = () => showAdminUserDetail(u.user_id); + list.appendChild(div); + }); + lucide.createIcons(); +} + +async function showAdminUserDetail(targetId) { + const res = await fetch(`${API_BASE}/admin/user/${targetId}?user_id=${currentState.user.id}`); + const data = await res.json(); + + currentState.admin_target = targetId; + + const container = document.getElementById('admin-content'); + container.innerHTML = document.getElementById('admin-user-detail').innerHTML; + + document.getElementById('adm-user-name').textContent = data.user.username || 'User'; + document.getElementById('adm-user-id').textContent = `ID: ${data.user.user_id}`; + + // Detail Avatar + const detailAvatar = document.getElementById('adm-user-avatar'); + if (detailAvatar) { + const photoUrl = `/api/user-photo/${data.user.user_id}`; + detailAvatar.innerHTML = ``; + } + + const m = data.marzban || {}; + document.getElementById('adm-user-status').textContent = m.status || 'Inactive'; + + const subUntil = data.user.subscription_until ? new Date(data.user.subscription_until) : null; + let expStr = 'None'; + if (subUntil) { + expStr = subUntil.getFullYear() >= 2099 ? '∞' : subUntil.toLocaleDateString(); + } + document.getElementById('adm-user-expire').textContent = expStr; + + const used = (m.used_traffic / (1024 ** 3)).toFixed(2); + const limitGB = m.data_limit / (1024 ** 3); + const limitStr = limitGB > 900000 ? '∞' : limitGB.toFixed(2); + document.getElementById('adm-user-traffic').textContent = `${used} / ${limitStr} GB`; + + lucide.createIcons(); +} + +async function adminUserAction(action) { + if (action === 'toggle_status' || action === 'reset_traffic' || action === 'delete_sub') { + const config = { + 'toggle_status': { title: 'Toggle Status', msg: 'Change user active/disabled state?', btnClass: 'btn-primary' }, + 'reset_traffic': { title: 'Reset Traffic', msg: 'Reset used traffic to zero?', btnClass: 'btn-primary' }, + 'delete_sub': { title: 'Delete Subscription', msg: 'Are you sure? User will lose VPN access immediately.', btnClass: 'btn-error' } + }; + const cfg = config[action]; + openModal(cfg.title, ` +
+

${cfg.msg}

+
+ + +
+
+ `); + return; + } + + let title = ""; + let html = ""; + + if (action === 'add_days') { + title = "Add Days"; + html = ` + + `; + } else if (action === 'set_limit') { + title = "Set Traffic Limit"; + html = ` + + `; + } else if (action === 'set_expiry') { + title = "Set Expiration"; + html = ` + + `; + } else if (action === 'set_plan') { + title = "Select Plan"; + const res = await fetch(`${API_BASE}/admin/plans_full?user_id=${currentState.user.id}`); + const allPlans = await res.json(); + + html = `
`; + allPlans.forEach(p => { + html += ` +
+
+
${p.name}
+
${p.days}d / ${p.data_limit || '∞'}GB
+
+ +
+ `; + }); + html += `
`; + } + + openModal(title, html); + lucide.createIcons(); +} + +async function submitAdminAction(action) { + const input = document.getElementById('adm-action-val'); + if (!input) return; + const value = input.value; + + let payload = { user_id: currentState.user.id, action: action }; + if (action === 'add_days' || action === 'set_expiry') payload.days = parseInt(value); + if (action === 'set_limit') payload.limit_gb = parseFloat(value); + + const res = await fetch(`${API_BASE}/admin/user/${currentState.admin_target}/action`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (res.ok) { + closeModal(); + showToast("Success!"); + showAdminUserDetail(currentState.admin_target); + } else { + showToast("Failed", "error"); + } +} + +async function submitAdminPlan(planId) { + const res = await fetch(`${API_BASE}/admin/user/${currentState.admin_target}/action`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_id: currentState.user.id, + action: 'set_plan', + plan_id: planId + }) + }); + + if (res.ok) { + closeModal(); + showToast("Plan Applied!"); + showAdminUserDetail(currentState.admin_target); + } else { + showToast("Failed", "error"); + } +} + +async function submitConfirmAction(action) { + const res = await fetch(`${API_BASE}/admin/user/${currentState.admin_target}/action`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_id: currentState.user.id, action: action }) + }); + + if (res.ok) { + closeModal(); + showToast("Success!"); + showAdminUserDetail(currentState.admin_target); + } else { + showToast("Action failed", "error"); + } +} + +async function loadAdminPromos() { + const res = await fetch(`${API_BASE}/admin/promos?user_id=${currentState.user.id}`); + const promos = await res.json(); + const list = document.getElementById('admin-promos-list'); + list.innerHTML = ''; + + promos.forEach(p => { + const div = document.createElement('div'); + div.className = 'list-item glass'; + div.style.marginBottom = '8px'; + const expDate = p.expires_at ? new Date(p.expires_at).toLocaleDateString() : 'Never'; + div.innerHTML = ` +
+
CODE: ${p.code}
+
${p.discount}% | Uses: ${p.uses_left} | Exp: ${expDate}
+
+ + `; + list.appendChild(div); + }); + lucide.createIcons(); +} + +async function deleteAdminPromo(code) { + openModal("Delete Promo", ` +
+

Are you sure you want to delete promo code ${code}?

+
+ + +
+
+ `); +} + +async function submitDeletePromo(code) { + const res = await fetch(`${API_BASE}/admin/promo/${code}?user_id=${currentState.user.id}`, { + method: 'DELETE' + }); + + if (res.ok) { + closeModal(); + showToast("Promo deleted!"); + loadAdminPromos(); + } else { + showToast("Failed to delete", "error"); + } +} + +function openCreatePromoModal() { + openModal("New Promo", ` +
+ +
+ + +
+
+ + +
+ + + + + +

* Sticky promo locks the discount for the user forever.

+ + +
+ `); +} + +async function createPromo() { + const payload = { + user_id: currentState.user.id, + code: document.getElementById('new-promo-code').value.toUpperCase(), + discount: parseInt(document.getElementById('new-promo-discount').value || 0), + uses: parseInt(document.getElementById('new-promo-uses').value || 1), + days: parseInt(document.getElementById('new-promo-days').value || 0), + bonus_days: parseInt(document.getElementById('new-promo-bonus').value || 0), + is_unlimited: document.getElementById('new-promo-unlim').checked, + is_sticky: document.getElementById('new-promo-sticky').checked + }; + + const res = await fetch(`${API_BASE}/admin/promos/create`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (res.ok) { + closeModal(); + showToast("Promo Created!"); + adminTab('promos'); + } else { + showToast("Error creating promo", "error"); + } +} + +async function sendBroadcast() { + const msg = document.getElementById('broadcast-msg').value; + if (!msg) return; + + if (!confirm("Send this message to ALL users?")) return; + + const res = await fetch(`${API_BASE}/admin/broadcast`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_id: currentState.user.id, message: msg }) + }); + + const data = await res.json(); + showToast(`Broadcast sent to ${data.sent} users!`); +} + +function openPromoModal() { + openModal("Promo Code", ` +
+

+ Enter your promo code to get discounts or bonus days. +

+ + +
+ `); +} + +function showToast(message, type = 'info') { + const container = document.getElementById('toast-container'); + if (!container) return; + + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + + const icons = { + 'info': 'info', + 'success': 'check-circle', + 'error': 'alert-circle' + }; + + toast.innerHTML = ` + + ${message} + `; + + container.appendChild(toast); + + if (window.lucide) lucide.createIcons(); + + // Remove after delay + setTimeout(() => { + toast.classList.add('toast-exit'); + // Wait for animation + setTimeout(() => { + toast.remove(); + }, 400); + }, 3000); +} + + +// App Start +document.addEventListener('DOMContentLoaded', initApp);