Update WebApp
This commit is contained in:
@@ -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},
|
||||
}
|
||||
|
||||
12
database.py
12
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(
|
||||
|
||||
@@ -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 = "🏷 <b>Активные промокоды:</b>\n\n"
|
||||
if not promos:
|
||||
text = "Нет активных промокодов."
|
||||
|
||||
text = "🏷 <b>Управление промокодами:</b>\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
|
||||
|
||||
exp_str = exp_dt.strftime('%d.%m.%Y') if exp_dt else "∞"
|
||||
# Filter expired
|
||||
if exp_dt and exp_dt < now:
|
||||
continue
|
||||
|
||||
# Получаем значения по ключам (не через get)
|
||||
exp_str = exp_dt.strftime('%d.%m.%Y') if exp_dt else "∞"
|
||||
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"🔹 <code>{p['code']}</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:
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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']} ⭐",
|
||||
|
||||
6
main.py
6
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:
|
||||
|
||||
361
server.py
361
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")
|
||||
|
||||
|
||||
@@ -20,3 +20,4 @@ class AdminUserStates(StatesGroup):
|
||||
waiting_for_days = State()
|
||||
waiting_for_message = State()
|
||||
waiting_for_limit = State()
|
||||
waiting_for_fixed_days = State()
|
||||
|
||||
51
web_app/static/css/dark-hc.css
Normal file
51
web_app/static/css/dark-hc.css
Normal file
@@ -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);
|
||||
}
|
||||
51
web_app/static/css/dark-mc.css
Normal file
51
web_app/static/css/dark-mc.css
Normal file
@@ -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);
|
||||
}
|
||||
53
web_app/static/css/dark.css
Normal file
53
web_app/static/css/dark.css
Normal file
@@ -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);
|
||||
}
|
||||
51
web_app/static/css/light-hc.css
Normal file
51
web_app/static/css/light-hc.css
Normal file
@@ -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);
|
||||
}
|
||||
51
web_app/static/css/light-mc.css
Normal file
51
web_app/static/css/light-mc.css
Normal file
@@ -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);
|
||||
}
|
||||
53
web_app/static/css/light.css
Normal file
53
web_app/static/css/light.css
Normal file
@@ -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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,232 +4,416 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>Comet VPN</title>
|
||||
<title>Stellarisei VPN</title>
|
||||
<!-- Telegram Web App SDK -->
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap"
|
||||
rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/style.css?v=2">
|
||||
|
||||
<!-- Icons -->
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||
|
||||
<!-- Styles -->
|
||||
<link rel="stylesheet" href="css/dark.css">
|
||||
<link rel="stylesheet" href="css/light.css">
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<body class="dark">
|
||||
|
||||
<div id="stars-container"></div>
|
||||
<div class="glow-overlay"></div>
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<div class="app-layout">
|
||||
<!-- Sidebar (Desktop) -->
|
||||
<aside class="sidebar glass">
|
||||
<div class="logo">
|
||||
<i data-lucide="rocket" class="logo-icon"></i>
|
||||
<span>CometBot</span>
|
||||
<div class="app-shell">
|
||||
<!-- Desktop Rail (Full Features) -->
|
||||
<nav class="nav-rail">
|
||||
<div class="logo-area">
|
||||
<i data-lucide="rocket" style="width:32px; height:32px;"></i>
|
||||
</div>
|
||||
<nav class="nav-menu">
|
||||
<button class="nav-item active" data-page="dashboard" onclick="router('dashboard')">
|
||||
<i data-lucide="layout-dashboard"></i> <span>Dashboard</span>
|
||||
</button>
|
||||
<button class="nav-item" data-page="shop" onclick="router('shop')">
|
||||
<i data-lucide="shopping-bag"></i> <span>Shop</span>
|
||||
</button>
|
||||
<button class="nav-item" data-page="subscription" onclick="router('subscription')">
|
||||
<i data-lucide="key"></i> <span>Config</span>
|
||||
</button>
|
||||
<button class="nav-item" data-page="profile" onclick="router('profile')">
|
||||
<i data-lucide="user"></i> <span>Profile</span>
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="content-area">
|
||||
<header class="mobile-header glass">
|
||||
<div class="logo-mini">
|
||||
<i data-lucide="rocket"></i> Comet
|
||||
<div class="nav-destinations">
|
||||
<button class="rail-item active nav-item" data-page="dashboard" onclick="router('dashboard')">
|
||||
<i data-lucide="layout-grid"></i>
|
||||
<span data-t="nav_home">Home</span>
|
||||
</button>
|
||||
<button class="rail-item nav-item" data-page="shop" onclick="router('shop')">
|
||||
<i data-lucide="shopping-bag"></i>
|
||||
<span data-t="nav_shop">Shop</span>
|
||||
</button>
|
||||
<button class="rail-item nav-item" data-page="subscription" onclick="router('subscription')">
|
||||
<i data-lucide="qr-code"></i>
|
||||
<span data-t="nav_config">Config</span>
|
||||
</button>
|
||||
<button class="rail-item nav-item" data-page="promo" onclick="openPromoModal()">
|
||||
<i data-lucide="ticket"></i>
|
||||
<span data-t="nav_promo">Promo</span>
|
||||
</button>
|
||||
<button class="rail-item nav-item" data-page="profile" onclick="router('profile')">
|
||||
<i data-lucide="user"></i>
|
||||
<span data-t="nav_profile">Profile</span>
|
||||
</button>
|
||||
<button class="rail-item nav-item hidden" id="nav-admin" data-page="admin" onclick="router('admin')">
|
||||
<i data-lucide="shield-check"></i>
|
||||
<span data-t="nav_admin">Admin</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="rail-fab" onclick="openSupport()">
|
||||
<i data-lucide="message-square"></i>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content Pane -->
|
||||
<main class="main-pane">
|
||||
<header class="mobile-header">
|
||||
<div style="display:flex; align-items:center; gap:12px;">
|
||||
<i data-lucide="rocket" style="color:var(--md-sys-color-primary)"></i>
|
||||
<span style="font-weight:600; font-size:18px;" id="header-title">Stellarisei</span>
|
||||
</div>
|
||||
<div class="user-chip" id="header-user">
|
||||
<div class="avatar-xs" id="header-avatar">U</div>
|
||||
<div class="user-chip" id="header-user" onclick="router('profile')">
|
||||
<div class="avatar-xs" id="header-avatar"
|
||||
style="width:32px;height:32px;border-radius:50%;background:var(--md-sys-color-primary-container);display:flex;align-items:center;justify-content:center;">
|
||||
U</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="app-view" class="view-container">
|
||||
<!-- View Display -->
|
||||
<div id="app-view" class="view-content">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Nav (Mobile) -->
|
||||
<nav class="bottom-nav glass">
|
||||
<button class="nav-item active" data-page="dashboard" onclick="router('dashboard')">
|
||||
<i data-lucide="layout-dashboard"></i>
|
||||
</button>
|
||||
<button class="nav-item" data-page="shop" onclick="router('shop')">
|
||||
<i data-lucide="shopping-bag"></i>
|
||||
</button>
|
||||
<button class="nav-item center-fab" data-page="subscription" onclick="router('subscription')">
|
||||
<div class="fab-bg"><i data-lucide="power"></i></div>
|
||||
</button>
|
||||
<button class="nav-item" data-page="profile" onclick="router('profile')">
|
||||
<i data-lucide="user"></i>
|
||||
</button>
|
||||
<button class="nav-item" onclick="openHelp()">
|
||||
<i data-lucide="help-circle"></i>
|
||||
</button>
|
||||
</nav>
|
||||
<div style="height:120px;"></div> <!-- Bottom spacer for mobile -->
|
||||
</main>
|
||||
|
||||
<!-- Mobile Bottom Bar (5 Items) -->
|
||||
<nav class="bottom-bar">
|
||||
<button class="bar-item nav-item active" data-page="dashboard" onclick="router('dashboard')">
|
||||
<div class="bar-pill"><i data-lucide="layout-grid"></i></div>
|
||||
<span style="font-size:10px; margin-top:4px;" data-t="nav_home">Home</span>
|
||||
</button>
|
||||
<button class="bar-item nav-item" data-page="shop" onclick="router('shop')">
|
||||
<div class="bar-pill"><i data-lucide="shopping-bag"></i></div>
|
||||
<span style="font-size:10px; margin-top:4px;" data-t="nav_shop">Shop</span>
|
||||
</button>
|
||||
<!-- Center Action: Config -->
|
||||
<button class="bar-item nav-item" data-page="subscription" onclick="router('subscription')">
|
||||
<div class="bar-pill"><i data-lucide="qr-code"></i></div>
|
||||
<span style="font-size:10px; margin-top:4px;" data-t="nav_config">Config</span>
|
||||
</button>
|
||||
<button class="bar-item nav-item" data-page="promo" onclick="openPromoModal()">
|
||||
<div class="bar-pill"><i data-lucide="ticket"></i></div>
|
||||
<span style="font-size:10px; margin-top:4px;" data-t="nav_promo">Promo</span>
|
||||
</button>
|
||||
<!-- Profile (hidden for admins) -->
|
||||
<button class="bar-item nav-item" id="mobile-profile-btn" data-page="profile" onclick="router('profile')">
|
||||
<div class="bar-pill"><i data-lucide="user"></i></div>
|
||||
<span style="font-size:10px; margin-top:4px;" data-t="nav_profile">Profile</span>
|
||||
</button>
|
||||
<!-- Admin (hidden by default, shown for admins) -->
|
||||
<button class="bar-item nav-item hidden" id="mobile-admin-btn" data-page="admin" onclick="router('admin')">
|
||||
<div class="bar-pill"><i data-lucide="shield-check"></i></div>
|
||||
<span style="font-size:10px; margin-top:4px;" data-t="nav_admin">Admin</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- VIEW TEMPLATES -->
|
||||
<!-- Modals -->
|
||||
<div id="modal-overlay" class="modal-overlay hidden" onclick="closeModal(event)">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-header"
|
||||
style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||
<h2 id="modal-title" style="font:var(--type-headline-small);">Title</h2>
|
||||
<button class="close-btn" onclick="closeModal()"
|
||||
style="background:none; border:none; color:var(--md-sys-color-on-surface-variant); cursor:pointer;"><i
|
||||
data-lucide="x"></i></button>
|
||||
</div>
|
||||
<div id="modal-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TEMPLATES -->
|
||||
|
||||
<!-- Dashboard -->
|
||||
<template id="view-dashboard">
|
||||
<div class="view-header">
|
||||
<h1>Overview</h1>
|
||||
<p class="subtitle">Welcome back, <span id="user-name">User</span></p>
|
||||
</div>
|
||||
<header class="page-header-large">
|
||||
<div class="subtitle" data-t="dash_welcome">Welcome back</div>
|
||||
<h1><span id="user-name">User</span></h1>
|
||||
</header>
|
||||
|
||||
<div class="status-card glass">
|
||||
<div class="status-ring">
|
||||
<svg viewBox="0 0 36 36" class="circular-chart">
|
||||
<path class="circle-bg"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
|
||||
<path class="circle" id="data-ring" stroke-dasharray="0, 100"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
|
||||
</svg>
|
||||
<div class="ring-content">
|
||||
<span class="value" id="dash-data-left">0</span>
|
||||
<span class="unit">GB Used</span>
|
||||
<div class="status-overview">
|
||||
<div class="ring-card">
|
||||
<div class="ring-container">
|
||||
<svg viewBox="0 0 36 36" class="circular-chart">
|
||||
<path class="circle-bg"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
|
||||
<path class="circle" id="data-ring" stroke-dasharray="0, 100"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
|
||||
</svg>
|
||||
<div
|
||||
style="position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;">
|
||||
<i data-lucide="database" style="color:var(--md-sys-color-primary)"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font:var(--type-title-large); color:var(--md-sys-color-on-surface);"><span
|
||||
id="dash-data-left">0</span> GB</div>
|
||||
<div style="font:var(--type-body-medium); color:var(--md-sys-color-outline);" data-t="dash_used">
|
||||
Used Traffic</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-details">
|
||||
<div class="detail-item">
|
||||
<span class="label">Status</span>
|
||||
<span class="val status-badge" id="dash-status">Checking...</span>
|
||||
|
||||
<div class="ring-card">
|
||||
<div class="ring-container">
|
||||
<svg viewBox="0 0 36 36" class="circular-chart">
|
||||
<path class="circle-bg"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
|
||||
<path class="circle" id="exp-ring" stroke-dasharray="0, 100"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
|
||||
</svg>
|
||||
<div
|
||||
style="position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;">
|
||||
<i data-lucide="clock" style="color:var(--md-sys-color-primary)"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="label">Plan Details</span>
|
||||
<span class="val" id="dash-limit">0 GB Total</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="label">Expires</span>
|
||||
<span class="val" id="dash-expire">-</span>
|
||||
<div>
|
||||
<div style="font:var(--type-title-large); color:var(--md-sys-color-on-surface);"><span
|
||||
id="dash-days-left">0</span> Days</div>
|
||||
<div style="font:var(--type-body-medium); color:var(--md-sys-color-outline);" data-t="dash_expiry">
|
||||
Until Expiry</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quick-actions">
|
||||
<button class="action-card glass" onclick="router('shop')">
|
||||
<div class="icon-circle pop"><i data-lucide="zap"></i></div>
|
||||
<span>Extend Plan</span>
|
||||
</button>
|
||||
<button class="action-card glass" onclick="router('subscription')">
|
||||
<div class="icon-circle"><i data-lucide="qr-code"></i></div>
|
||||
<span>Connect</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Shop -->
|
||||
<template id="view-shop">
|
||||
<div class="view-header">
|
||||
<h1>Shop</h1>
|
||||
<p class="subtitle">Choose your plan</p>
|
||||
</div>
|
||||
<div class="plans-list" id="plans-container">
|
||||
<!-- Injected -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Subscription -->
|
||||
<template id="view-subscription">
|
||||
<div class="view-header">
|
||||
<h1>Connection</h1>
|
||||
<p class="subtitle">Setup your VPN</p>
|
||||
</div>
|
||||
|
||||
<div class="card glass center-content">
|
||||
<div id="qrcode-container"></div>
|
||||
<p class="helper-text">Scan availability QR Code</p>
|
||||
|
||||
<div class="copy-box" onclick="copyConfig()">
|
||||
<div class="truncate-text" id="config-link">Loading...</div>
|
||||
<i data-lucide="copy"></i>
|
||||
<div class="m3-card">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
|
||||
<span class="subtitle" data-t="dash_status">Subscription Status</span>
|
||||
<span class="status-badge" id="dash-status"
|
||||
style="background:var(--md-sys-color-secondary-container); color:var(--md-sys-color-on-secondary-container); padding:4px 12px; border-radius:8px; font-weight:600; font-size:12px;">Checking...</span>
|
||||
</div>
|
||||
|
||||
<div class="sub-actions">
|
||||
<button class="btn-secondary" onclick="copyConfig()">Copy Link</button>
|
||||
<button class="btn-primary"
|
||||
onclick="window.Telegram.WebApp.openLink('https://apps.apple.com/us/app/v2box-v2ray-client/id6446814690')">
|
||||
Download App
|
||||
<div class="actions-grid">
|
||||
<button class="action-btn-large" onclick="router('shop')">
|
||||
<i data-lucide="zap"></i>
|
||||
<span data-t="btn_extend">Extend</span>
|
||||
</button>
|
||||
<button class="action-btn-large" onclick="router('subscription')">
|
||||
<i data-lucide="qr-code"></i>
|
||||
<span data-t="btn_connect">Connect</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="accordion glass">
|
||||
<div class="acc-item">
|
||||
<div class="acc-head" onclick="toggleAcc(this)">
|
||||
<span>How to connect on iOS?</span>
|
||||
<i data-lucide="chevron-down"></i>
|
||||
</div>
|
||||
<div class="acc-body">
|
||||
<ol>
|
||||
<li>Install <b>V2Box</b> from AppStore.</li>
|
||||
<li>Copy the link above.</li>
|
||||
<li>Open V2Box, it will detect the link.</li>
|
||||
<li>Tap "Import" and Connect.</li>
|
||||
</ol>
|
||||
<template id="view-shop">
|
||||
<header class="page-header-large">
|
||||
<h1 data-t="shop_title">Select Plan</h1>
|
||||
<div class="subtitle" data-t="shop_subtitle">Upgrade your experience</div>
|
||||
</header>
|
||||
<div id="plans-container" style="margin-bottom: 60px;"></div>
|
||||
</template>
|
||||
|
||||
<template id="view-subscription">
|
||||
<header class="page-header-large">
|
||||
<h1 data-t="sub_title">Connect</h1>
|
||||
<div class="subtitle" data-t="sub_subtitle">Setup your device</div>
|
||||
</header>
|
||||
|
||||
<div class="m3-card" style="display:flex; flex-direction:column; align-items:center; text-align:center;">
|
||||
<div id="qrcode-container" style="background:white; padding:12px; border-radius:16px;"></div>
|
||||
<div style="margin-top:24px; width:100%;">
|
||||
<div class="subtitle" style="margin-bottom:8px;" data-t="sub_link_label">Subscription Link</div>
|
||||
<div style="background:var(--md-sys-color-surface-container); padding:12px; border-radius:12px; display:flex; align-items:center; gap:12px; cursor:pointer;"
|
||||
onclick="copyConfig()">
|
||||
<div id="config-link"
|
||||
style="flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:13px; font-family:monospace; color:var(--md-sys-color-on-surface);">
|
||||
...</div>
|
||||
<i data-lucide="copy" style="color:var(--md-sys-color-primary);"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="device-apps-container"></div>
|
||||
|
||||
<div class="accordion m3-card" style="margin-top:16px;">
|
||||
<div class="acc-item">
|
||||
<div class="acc-head" onclick="toggleAcc(this)">
|
||||
<span>How to connect on Android?</span>
|
||||
<span data-t="sub_instructions">Detailed Instructions</span>
|
||||
<i data-lucide="chevron-down"></i>
|
||||
</div>
|
||||
<div class="acc-body">
|
||||
<ol>
|
||||
<li>Install <b>v2rayNG</b> or <b>Hiddify</b>.</li>
|
||||
<li>Copy the config link.</li>
|
||||
<li>Open app -> Import from Clipboard.</li>
|
||||
<li>Connect (V button).</li>
|
||||
</ol>
|
||||
<p data-t="sub_instr_1">1. Download app for your device.</p>
|
||||
<p data-t="sub_instr_2">2. Copy the link above.</p>
|
||||
<p data-t="sub_instr_3">3. Import from Clipboard.</p>
|
||||
<p data-t="sub_instr_4">4. Connect.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Profile -->
|
||||
<template id="view-profile">
|
||||
<div class="view-header">
|
||||
<h1>Profile</h1>
|
||||
<div style="text-align:center; margin:40px 0;">
|
||||
<div class="avatar-xs" id="profile-avatar"
|
||||
style="width:80px; height:80px; margin:0 auto 16px; border-radius:50%; background:var(--md-sys-color-primary-container); color:var(--md-sys-color-on-primary-container); font-size:32px; display:flex; align-items:center; justify-content:center;">
|
||||
U</div>
|
||||
<h2 id="profile-name" style="font:var(--type-headline-medium);">User</h2>
|
||||
<div id="profile-id" class="subtitle">ID: 000000</div>
|
||||
</div>
|
||||
|
||||
<div class="card glass profile-main">
|
||||
<div class="big-avatar" id="profile-avatar">U</div>
|
||||
<h2 id="profile-name">User</h2>
|
||||
<p id="profile-id" class="id-badge">ID: 000000</p>
|
||||
<div class="m3-card">
|
||||
<div class="shop-item">
|
||||
<span data-t="prof_joined">Joined</span>
|
||||
<span id="stat-reg-date" style="font-weight:600;">...</span>
|
||||
</div>
|
||||
<div class="shop-item">
|
||||
<span data-t="prof_spent">Total Spent</span>
|
||||
<span style="font-weight:600;"><span id="stat-spent">0</span> ⭐️</span>
|
||||
</div>
|
||||
<div class="shop-item">
|
||||
<span data-t="prof_purchases">Purchases</span>
|
||||
<span id="stat-payments" style="font-weight:600;">0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Promo Code</div>
|
||||
<div class="card glass promo-card">
|
||||
<input type="text" id="promo-input" placeholder="ENTER CODE" class="glass-input">
|
||||
<button class="btn-small" onclick="checkPromo()">Apply</button>
|
||||
</div>
|
||||
<div id="promo-result"></div>
|
||||
|
||||
<div class="list-menu glass">
|
||||
<button class="list-item" onclick="window.Telegram.WebApp.openLink('https://t.me/hoshimach1')">
|
||||
<!-- Restored Support/About Buttons -->
|
||||
<h3 style="margin-bottom:12px; padding-left:4px;" data-t="prof_app_info">App Info</h3>
|
||||
<div class="m3-card" style="padding:12px;">
|
||||
<button class="list-item" onclick="openSupport()" style="width:100%;">
|
||||
<i data-lucide="message-square"></i>
|
||||
<span>Support</span>
|
||||
<span data-t="btn_support">Support</span>
|
||||
<i data-lucide="chevron-right" class="arrow"></i>
|
||||
</button>
|
||||
<button class="list-item" onclick="alert('v1.0.0 Comet')">
|
||||
<button class="list-item" onclick="openAbout()" style="width:100%;">
|
||||
<i data-lucide="info"></i>
|
||||
<span>About</span>
|
||||
<span data-t="btn_about">About</span>
|
||||
<i data-lucide="chevron-right" class="arrow"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="js/background.js"></script>
|
||||
<!-- Admin templates -->
|
||||
<template id="view-admin">
|
||||
<header class="page-header-large">
|
||||
<h1 data-t="adm_title">Admin</h1>
|
||||
</header>
|
||||
<div class="admin-tabs" style="display:flex; gap:8px; overflow-x:auto; margin-bottom:24px; padding-bottom:4px;">
|
||||
<button class="btn-tonal tab-item" onclick="adminTab('stats')" data-t="adm_stats">Stats</button>
|
||||
<button class="btn-tonal tab-item" onclick="adminTab('users')" data-t="adm_users">Users</button>
|
||||
<button class="btn-tonal tab-item" onclick="adminTab('promos')" data-t="adm_promos">Promos</button>
|
||||
<button class="btn-tonal tab-item" onclick="adminTab('broadcast')" data-t="adm_broadcast">Broadcast</button>
|
||||
</div>
|
||||
<div id="admin-content"></div>
|
||||
</template>
|
||||
|
||||
<template id="admin-stats">
|
||||
<div class="m3-card">
|
||||
<h3 data-t="adm_bot_stats">Bot Stats</h3>
|
||||
<div
|
||||
style="margin-top:12px; display:flex; justify-content:space-between; padding:8px 0; border-bottom:1px solid var(--md-sys-color-outline-variant);">
|
||||
<span data-t="adm_total_users">Total Users:</span> <b id="adm-total-users">0</b>
|
||||
</div>
|
||||
<div
|
||||
style="display:flex; justify-content:space-between; padding:8px 0; border-bottom:1px solid var(--md-sys-color-outline-variant);">
|
||||
<span data-t="adm_active_subs">Active Subs:</span> <b id="adm-active-subs">0</b>
|
||||
</div>
|
||||
<div style="display:flex; justify-content:space-between; padding:8px 0;">
|
||||
<span data-t="adm_revenue">Revenue:</span> <b id="adm-revenue">0</b>
|
||||
</div>
|
||||
</div>
|
||||
<div class="m3-card">
|
||||
<h3 data-t="adm_server">Server</h3>
|
||||
<div
|
||||
style="margin-top:12px; display:flex; justify-content:space-between; padding:8px 0; border-bottom:1px solid var(--md-sys-color-outline-variant);">
|
||||
<span>CPU:</span> <b id="adm-cpu">0%</b>
|
||||
</div>
|
||||
<div
|
||||
style="display:flex; justify-content:space-between; padding:8px 0; border-bottom:1px solid var(--md-sys-color-outline-variant);">
|
||||
<span>RAM:</span> <b id="adm-ram">0/0</b>
|
||||
</div>
|
||||
<div style="display:flex; justify-content:space-between; padding:8px 0;">
|
||||
<span data-t="adm_marzban">Marzban Users:</span> <b id="adm-active-marz">0</b>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="admin-users">
|
||||
<div class="search-box">
|
||||
<input type="text" id="admin-user-search" placeholder="Search..." data-tp="ph_search" class="glass-input">
|
||||
<button class="btn-primary" style="width:auto; padding:0 24px;" onclick="adminSearchUsers()"
|
||||
data-t="btn_search">Search</button>
|
||||
</div>
|
||||
<div id="admin-users-list" class="admin-list"></div>
|
||||
</template>
|
||||
|
||||
<template id="admin-user-detail">
|
||||
<button class="btn-secondary" onclick="adminTab('users')"
|
||||
style="margin-bottom:16px; width:auto; padding:0 20px;"><i data-lucide="arrow-left"></i> <span
|
||||
data-t="btn_back">Back</span></button>
|
||||
<div class="m3-card" style="margin-bottom:50px;">
|
||||
<div style="display:flex; align-items:center; gap:16px; margin-bottom:20px;">
|
||||
<div class="avatar-xs" id="adm-user-avatar"
|
||||
style="width:48px;height:48px;border-radius:50%;background:var(--md-sys-color-primary-container);display:flex;align-items:center;justify-content:center;">
|
||||
U</div>
|
||||
<div>
|
||||
<div style="font-weight:700; font-size:18px;" id="adm-user-name">User</div>
|
||||
<div style="font-size:12px;opacity:0.6;" id="adm-user-id">ID: ...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-bottom:24px;">
|
||||
<div class="list-item" style="flex-direction:column; align-items:flex-start; gap:4px;">
|
||||
<span style="font-size:11px; opacity:0.6" data-t="adm_status">Status</span>
|
||||
<b id="adm-user-status">...</b>
|
||||
</div>
|
||||
<div class="list-item" style="flex-direction:column; align-items:flex-start; gap:4px;">
|
||||
<span style="font-size:11px; opacity:0.6" data-t="adm_expiry">Expiry</span>
|
||||
<b id="adm-user-expire">...</b>
|
||||
</div>
|
||||
<div class="list-item"
|
||||
style="grid-column: span 2; flex-direction:column; align-items:flex-start; gap:4px;">
|
||||
<span style="font-size:11px; opacity:0.6" data-t="adm_traffic">Traffic</span>
|
||||
<b id="adm-user-traffic">...</b>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:8px;">
|
||||
<button class="btn-secondary" onclick="adminUserAction('add_days')" data-t="adm_btn_days">+Days</button>
|
||||
<button class="btn-secondary" onclick="adminUserAction('set_expiry')" data-t="adm_btn_exp">Set
|
||||
Exp</button>
|
||||
<button class="btn-secondary" onclick="adminUserAction('set_limit')" data-t="adm_btn_limit">Set
|
||||
GB</button>
|
||||
<button class="btn-secondary" onclick="adminUserAction('set_plan')" data-t="adm_btn_plan">Plan</button>
|
||||
<button class="btn-secondary" onclick="adminUserAction('toggle_status')"
|
||||
data-t="adm_btn_toggle">Toggle</button>
|
||||
<button class="btn-secondary" onclick="adminUserAction('reset_traffic')"
|
||||
data-t="adm_btn_reset">Reset</button>
|
||||
<button class="btn-error" style="grid-column:span 2" onclick="adminUserAction('delete_sub')"
|
||||
data-t="adm_btn_delete">Delete
|
||||
Sub</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="admin-promos">
|
||||
<button class="btn-primary" style="margin-bottom:16px;" onclick="openCreatePromoModal()"
|
||||
data-t="adm_create_promo">+ Create Promo</button>
|
||||
<div id="admin-promos-list" class="admin-list"></div>
|
||||
</template>
|
||||
|
||||
<template id="admin-broadcast">
|
||||
<div class="m3-card">
|
||||
<h3 data-t="adm_broadcast">Broadcast</h3>
|
||||
<p class="subtitle" style="margin-bottom:16px;" data-t="adm_broadcast_msg">Send message to all users</p>
|
||||
<textarea id="broadcast-msg" class="glass-input"
|
||||
style="height:120px; border-radius:12px; margin-bottom:16px;" placeholder="Message..."
|
||||
data-tp="ph_message"></textarea>
|
||||
<button class="btn-primary" onclick="sendBroadcast()" data-t="adm_send">Send</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- JS -->
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user