feat: tune visual of web app admin panel

This commit is contained in:
3252a8
2026-05-10 23:36:05 +03:00
parent 410cb89f3e
commit 57821ba2a9
6 changed files with 1449 additions and 354 deletions
+184 -61
View File
@@ -19,7 +19,7 @@ from typing import Any, Dict, List, Optional
from aiogram import Bot from aiogram import Bot
from aiohttp import web from aiohttp import web
from pydantic import ValidationError from pydantic import ValidationError
from sqlalchemy import or_, select from sqlalchemy import func as sa_func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
@@ -163,6 +163,11 @@ def _serialize_user(user: User) -> Dict[str, Any]:
def _serialize_subscription(sub: Subscription) -> Dict[str, Any]: def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
premium_limit_bytes = (
int(sub.premium_baseline_bytes or 0)
+ int(sub.premium_topup_balance_bytes or 0)
+ int(getattr(sub, "premium_topup_used_bytes", 0) or 0)
)
return { return {
"subscription_id": int(sub.subscription_id), "subscription_id": int(sub.subscription_id),
"panel_user_uuid": sub.panel_user_uuid, "panel_user_uuid": sub.panel_user_uuid,
@@ -174,9 +179,18 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
"status_from_panel": sub.status_from_panel, "status_from_panel": sub.status_from_panel,
"traffic_limit_bytes": sub.traffic_limit_bytes, "traffic_limit_bytes": sub.traffic_limit_bytes,
"traffic_used_bytes": sub.traffic_used_bytes, "traffic_used_bytes": sub.traffic_used_bytes,
"tier_baseline_bytes": sub.tier_baseline_bytes,
"topup_balance_bytes": sub.topup_balance_bytes,
"premium_used_bytes": sub.premium_used_bytes,
"premium_limit_bytes": premium_limit_bytes,
"premium_baseline_bytes": sub.premium_baseline_bytes,
"premium_topup_balance_bytes": sub.premium_topup_balance_bytes,
"premium_topup_used_bytes": getattr(sub, "premium_topup_used_bytes", 0),
"premium_is_limited": bool(sub.premium_is_limited),
"tariff_key": sub.tariff_key, "tariff_key": sub.tariff_key,
"auto_renew_enabled": bool(sub.auto_renew_enabled), "auto_renew_enabled": bool(sub.auto_renew_enabled),
"provider": sub.provider, "provider": sub.provider,
"is_throttled": bool(sub.is_throttled),
} }
@@ -335,20 +349,20 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
page = max(0, int(request.query.get("page", 0) or 0)) page = max(0, int(request.query.get("page", 0) or 0))
page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25))) page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25)))
query = (request.query.get("q") or "").strip() query = (request.query.get("q") or "").strip()
only_banned = (request.query.get("filter") or "").lower() == "banned" filter_value = (request.query.get("filter") or "all").lower()
panel_status = (request.query.get("panel_status") or "all").lower()
sort_value = (request.query.get("sort") or "registered_desc").lower()
async with async_session_factory() as session: async with async_session_factory() as session:
if query: users, total = await _filter_and_sort_users(
users = await _search_users(session, query, page=page, page_size=page_size) session,
total = len(users) # cheap upper bound; UI shows hasMore via len==page_size query=query,
elif only_banned: filter_value=filter_value,
banned = await user_dal.get_banned_users(session) panel_status=panel_status,
total = len(banned) sort_value=sort_value,
start = page * page_size page=page,
users = banned[start : start + page_size] page_size=page_size,
else: )
users = await user_dal.get_all_users_paginated(session, page=page, page_size=page_size)
total = await user_dal.count_all_users(session)
statuses = await _bulk_user_statuses(session, [u.user_id for u in users]) statuses = await _bulk_user_statuses(session, [u.user_id for u in users])
cached_avatar_ids = await _bulk_user_avatar_keys(session, [u.user_id for u in users]) cached_avatar_ids = await _bulk_user_avatar_keys(session, [u.user_id for u in users])
@@ -356,7 +370,10 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
serialized = [] serialized = []
for user in users: for user in users:
payload = _serialize_user(user) payload = _serialize_user(user)
payload["panel_status"] = statuses.get(user.user_id) status_payload = statuses.get(user.user_id) or {"status": "bot_only", "end_date": None}
payload["panel_status"] = status_payload.get("status")
if status_payload.get("status") == "expired" and status_payload.get("end_date"):
payload["panel_status_expired_at"] = status_payload["end_date"]
payload["avatar_url"] = ( payload["avatar_url"] = (
f"/api/admin/users/{user.user_id}/avatar?v={cached_avatar_ids[user.user_id]}" f"/api/admin/users/{user.user_id}/avatar?v={cached_avatar_ids[user.user_id]}"
if user.user_id in cached_avatar_ids if user.user_id in cached_avatar_ids
@@ -376,7 +393,7 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
async def _bulk_user_statuses( async def _bulk_user_statuses(
session: AsyncSession, user_ids: List[int] session: AsyncSession, user_ids: List[int]
) -> Dict[int, Optional[str]]: ) -> Dict[int, Dict[str, Optional[str]]]:
"""Return active subscription status for a batch of users. """Return active subscription status for a batch of users.
Returns the panel status (active/expired/limited/disabled) when an active Returns the panel status (active/expired/limited/disabled) when an active
@@ -392,18 +409,21 @@ async def _bulk_user_statuses(
.order_by(Subscription.is_active.desc(), Subscription.end_date.desc().nullslast()) .order_by(Subscription.is_active.desc(), Subscription.end_date.desc().nullslast())
) )
rows = (await session.execute(stmt)).all() rows = (await session.execute(stmt)).all()
out: Dict[int, str] = {} out: Dict[int, Dict[str, Optional[str]]] = {}
for uid, panel_status, is_active, _end in rows: for uid, panel_status, is_active, end_date in rows:
if uid in out: if uid in out:
continue continue
if is_active: if is_active:
status = (panel_status or "active").lower() status = (panel_status or "active").lower()
else: else:
status = (panel_status or "expired").lower() status = (panel_status or "expired").lower()
out[uid] = status out[uid] = {
"status": status,
"end_date": end_date.isoformat() if end_date else None,
}
for uid in user_ids: for uid in user_ids:
if uid not in out: if uid not in out:
out[uid] = "bot_only" out[uid] = {"status": "bot_only", "end_date": None}
return out return out
@@ -466,59 +486,118 @@ async def admin_user_avatar_route(request: web.Request) -> web.Response:
return response return response
async def _search_users( async def _filter_and_sort_users(
session: AsyncSession, query: str, *, page: int, page_size: int session: AsyncSession,
) -> List[User]: *,
"""Best-effort search that tries telegram id, username, email.""" query: str = "",
filter_value: str,
panel_status: str = "all",
sort_value: str,
page: int,
page_size: int,
) -> tuple[List[User], int]:
"""Return paginated users with optional search, filter and sort applied."""
candidates: List[User] = [] stmt = select(User)
seen: set = set() count_stmt = select(sa_func.count(User.user_id))
if query.isdigit(): search_cond = _user_search_condition(query)
user = await user_dal.get_user_by_id(session, int(query)) if search_cond is not None:
if user: stmt = stmt.where(search_cond)
candidates.append(user) count_stmt = count_stmt.where(search_cond)
seen.add(user.user_id)
user = await user_dal.get_user_by_telegram_id(session, int(query))
if user and user.user_id not in seen:
candidates.append(user)
seen.add(user.user_id)
if "@" in query and not query.startswith("@"): f = (filter_value or "all").lower()
user = await user_dal.get_user_by_email(session, query) if f == "banned":
if user and user.user_id not in seen: cond = User.is_banned.is_(True)
candidates.append(user) elif f == "active":
seen.add(user.user_id) cond = User.is_banned.is_(False)
elif f == "tg_linked":
cond = User.telegram_id.is_not(None)
elif f == "no_tg":
cond = User.telegram_id.is_(None)
elif f == "email_linked":
cond = User.email.is_not(None)
elif f == "no_email":
cond = User.email.is_(None)
elif f == "panel_linked":
cond = User.panel_user_uuid.is_not(None)
else:
cond = None
raw = query.lstrip("@") if cond is not None:
user = await user_dal.get_user_by_username(session, raw) stmt = stmt.where(cond)
if user and user.user_id not in seen: count_stmt = count_stmt.where(cond)
candidates.append(user)
seen.add(user.user_id) panel_cond = _user_panel_status_condition(panel_status)
if panel_cond is not None:
stmt = stmt.where(panel_cond)
count_stmt = count_stmt.where(panel_cond)
sort_map = {
"registered_desc": User.registration_date.desc().nullslast(),
"registered_asc": User.registration_date.asc().nullslast(),
"name_asc": (
sa_func.coalesce(User.first_name, User.username, User.email).asc(),
User.user_id.asc(),
),
"name_desc": (
sa_func.coalesce(User.first_name, User.username, User.email).desc(),
User.user_id.desc(),
),
"id_asc": User.user_id.asc(),
"id_desc": User.user_id.desc(),
}
order = sort_map.get(sort_value, sort_map["registered_desc"])
if isinstance(order, tuple):
stmt = stmt.order_by(*order)
else:
stmt = stmt.order_by(order)
stmt = stmt.offset(max(page, 0) * max(page_size, 1)).limit(max(page_size, 1))
users = (await session.execute(stmt)).scalars().all()
total = (await session.execute(count_stmt)).scalar_one()
return users, int(total)
def _user_panel_status_condition(panel_status: str):
status = (panel_status or "all").lower()
if status not in {"active", "expired", "limited"}:
return None
normalized_status = sa_func.lower(sa_func.coalesce(Subscription.status_from_panel, ""))
blank_status = or_(Subscription.status_from_panel.is_(None), Subscription.status_from_panel == "")
if status == "active":
status_cond = or_(normalized_status == "active", blank_status & Subscription.is_active.is_(True))
elif status == "expired":
status_cond = or_(normalized_status == "expired", blank_status & Subscription.is_active.is_(False))
else:
status_cond = normalized_status == "limited"
return (
select(Subscription.subscription_id)
.where(Subscription.user_id == User.user_id, status_cond)
.exists()
)
def _user_search_condition(query: str):
raw = (query or "").strip().lstrip("@")
if not raw:
return None
if not candidates:
like = f"%{raw}%" like = f"%{raw}%"
stmt = ( conditions = [
select(User)
.where(
or_(
User.username.ilike(like), User.username.ilike(like),
User.first_name.ilike(like), User.first_name.ilike(like),
User.last_name.ilike(like), User.last_name.ilike(like),
User.email.ilike(like), User.email.ilike(like),
) ]
) if raw.isdigit():
.order_by(User.registration_date.desc()) numeric = int(raw)
.offset(page * page_size) conditions.extend([User.user_id == numeric, User.telegram_id == numeric])
.limit(page_size)
)
rows = (await session.execute(stmt)).scalars().all()
for row in rows:
if row.user_id not in seen:
candidates.append(row)
seen.add(row.user_id)
return candidates return or_(*conditions)
async def admin_user_detail_route(request: web.Request) -> web.Response: async def admin_user_detail_route(request: web.Request) -> web.Response:
@@ -698,6 +777,49 @@ async def admin_user_message_route(request: web.Request) -> web.Response:
return _ok({}) return _ok({})
async def admin_user_message_preview_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
admin_telegram_id = request.get("admin_telegram_id")
target_id = int(request.match_info["user_id"])
payload = await _read_json(request)
text = str(payload.get("text") or "").strip()
if not text:
return _error(400, "empty_text")
if not admin_telegram_id:
return _error(403, "admin_telegram_unavailable")
queue_manager = get_queue_manager()
if not queue_manager:
return _error(503, "queue_unavailable")
try:
await send_message_via_queue(
queue_manager,
int(admin_telegram_id),
MessageContent(content_type="text", text=text),
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as exc:
logger.warning("Admin direct message preview failed: %s", exc)
return _error(502, "preview_failed", str(exc))
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_direct_message_preview_webapp",
"content": text[:4000],
"is_admin_event": True,
"target_user_id": target_id,
},
)
return _ok({})
async def admin_user_delete_route(request: web.Request) -> web.Response: async def admin_user_delete_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request) actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"]) target_id = int(request.match_info["user_id"])
@@ -1347,6 +1469,7 @@ def setup_admin_routes(app: web.Application) -> None:
router.add_get("/api/admin/users/{user_id:-?\\d+}/avatar", admin_user_avatar_route) router.add_get("/api/admin/users/{user_id:-?\\d+}/avatar", admin_user_avatar_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/ban", admin_user_ban_route) router.add_post("/api/admin/users/{user_id:-?\\d+}/ban", admin_user_ban_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/message", admin_user_message_route) router.add_post("/api/admin/users/{user_id:-?\\d+}/message", admin_user_message_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/message/preview", admin_user_message_preview_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/reset-trial", admin_user_reset_trial_route) router.add_post("/api/admin/users/{user_id:-?\\d+}/reset-trial", admin_user_reset_trial_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/extend", admin_user_extend_route) router.add_post("/api/admin/users/{user_id:-?\\d+}/extend", admin_user_extend_route)
router.add_delete("/api/admin/users/{user_id:-?\\d+}", admin_user_delete_route) router.add_delete("/api/admin/users/{user_id:-?\\d+}", admin_user_delete_route)
+5
View File
@@ -2963,6 +2963,11 @@
logoEmoji={brandEmoji} logoEmoji={brandEmoji}
appVersion={CFG.appVersion} appVersion={CFG.appVersion}
appRepositoryUrl={CFG.appRepositoryUrl} appRepositoryUrl={CFG.appRepositoryUrl}
currentLang={currentLang}
languageOptions={languageOptions}
languageBusy={languageBusy}
onLanguageChange={updateAccountLanguage}
t={t}
/> />
{:else} {:else}
<div class="phone-screen" class:home-screen={screen === "home"}> <div class="phone-screen" class:home-screen={screen === "home"}>
File diff suppressed because it is too large Load Diff
+513 -106
View File
@@ -1422,6 +1422,7 @@ a {
min-height: 50px; min-height: 50px;
padding: 9px 10px; padding: 9px 10px;
text-align: left; text-align: left;
background: rgba(255, 255, 255, 0.03);
} }
.settings-row > svg:first-child { .settings-row > svg:first-child {
@@ -2077,7 +2078,7 @@ a {
position: fixed; position: fixed;
right: 16px; right: 16px;
bottom: 16px; bottom: 16px;
z-index: 60; z-index: 120;
max-width: min(420px, calc(100vw - 32px)); max-width: min(420px, calc(100vw - 32px));
border: 1px solid color-mix(in srgb, var(--accent) 46%, var(--border)); border: 1px solid color-mix(in srgb, var(--accent) 46%, var(--border));
border-radius: var(--radius); border-radius: var(--radius);
@@ -2364,10 +2365,7 @@ a {
@media (min-width: 1024px) { @media (min-width: 1024px) {
body { body {
background: background: #02070b;
radial-gradient(900px 600px at 8% -10%, color-mix(in srgb, var(--accent) 9%, transparent) 0%, transparent 60%),
radial-gradient(900px 500px at 92% 110%, rgba(45, 156, 255, 0.07) 0%, transparent 55%),
#02070b;
} }
.app-shell { .app-shell {
@@ -2788,7 +2786,65 @@ a {
font-size: 11px; font-size: 11px;
color: var(--admin-dim); color: var(--admin-dim);
display: grid; display: grid;
gap: 6px; gap: 10px;
}
.admin-language-switch {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: center;
gap: 8px;
}
.admin-language-switch > svg {
color: var(--admin-muted);
}
.admin-language-trigger {
display: flex;
width: 100%;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 8px;
border: 1px solid var(--admin-border);
border-radius: 8px;
background: var(--admin-surface-2);
color: var(--admin-text);
padding: 8px 10px;
text-align: left;
cursor: pointer;
}
.admin-language-trigger:focus-visible {
outline: 2px solid var(--admin-ring);
outline-offset: 2px;
}
.admin-language-trigger > span {
display: grid;
min-width: 0;
gap: 2px;
}
.admin-language-trigger strong {
overflow: hidden;
font-size: 12px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-language-trigger small {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 5px;
overflow: hidden;
color: var(--admin-muted);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
} }
.admin-version-link { .admin-version-link {
@@ -3014,36 +3070,123 @@ a {
align-items: center; align-items: center;
} }
.admin-toolbar-users {
display: grid;
grid-template-columns: minmax(260px, 1fr);
gap: 10px;
padding: 12px;
border: 1px solid var(--admin-border);
border-radius: 12px;
background: var(--admin-surface);
}
.admin-toolbar-card {
display: grid;
grid-template-columns: minmax(260px, 1fr) auto;
align-items: end;
gap: 8px;
padding: 12px;
border: 1px solid var(--admin-border);
border-radius: 12px;
background: var(--admin-surface);
}
.admin-toolbar-search {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
min-width: 0;
}
.admin-toolbar-search-actions {
grid-template-columns: minmax(0, 1fr) auto auto;
}
.admin-toolbar-search .input,
.admin-toolbar-search .admin-btn {
height: 36px;
}
.admin-toolbar-controls {
display: grid;
grid-template-columns: repeat(3, minmax(150px, 1fr)) minmax(96px, auto);
align-items: end;
gap: 8px;
}
.admin-toolbar > .input, .admin-toolbar > .input,
.admin-toolbar input[type="text"], .admin-toolbar input[type="text"],
.admin-toolbar input[type="number"] { .admin-toolbar input[type="number"],
.admin-toolbar input[type="search"] {
flex: 1 1 200px; flex: 1 1 200px;
min-width: 180px; min-width: 180px;
} }
.admin-segmented { .admin-toolbar > .admin-btn {
display: inline-flex; height: 36px;
border: 1px solid var(--admin-border);
background: var(--admin-surface);
border-radius: 8px;
padding: 2px;
gap: 2px;
} }
.admin-segmented button { .admin-toolbar-field {
padding: 6px 12px; display: grid;
border: 0; align-items: start;
background: transparent; gap: 6px;
min-width: 0;
}
.admin-toolbar-field-label {
color: var(--admin-muted); color: var(--admin-muted);
font-size: 12px; font-size: 11px;
font-weight: 600; font-weight: 600;
border-radius: 6px; text-transform: uppercase;
cursor: pointer; letter-spacing: 0.04em;
white-space: nowrap;
} }
.admin-segmented button.active { .admin-toolbar-select {
background: var(--admin-elev); height: 36px;
min-width: 0;
}
.admin-toolbar-summary {
display: grid;
min-height: 36px;
min-width: 96px;
align-content: center;
justify-items: end;
gap: 2px;
padding: 0 2px;
white-space: nowrap;
}
.admin-toolbar-summary strong {
color: var(--admin-text); color: var(--admin-text);
font-size: 14px;
font-weight: 650;
line-height: 1;
}
@media (max-width: 900px) {
.admin-toolbar-controls {
grid-template-columns: repeat(2, minmax(150px, 1fr));
}
.admin-toolbar-summary {
justify-items: start;
}
}
@media (max-width: 560px) {
.admin-toolbar-search,
.admin-toolbar-search-actions,
.admin-toolbar-card,
.admin-toolbar-controls {
grid-template-columns: minmax(0, 1fr);
}
.admin-toolbar-summary {
justify-items: start;
}
} }
.admin-table-wrap { .admin-table-wrap {
@@ -3528,6 +3671,96 @@ a {
border-radius: 6px; border-radius: 6px;
} }
/* ============================================================
Admin skeletons (users list, payments / logs / promos / ads
tables). Rendered while data is loading to soften the empty
state. Kept aria-hidden in markup; purely decorative.
============================================================ */
.admin-skeleton {
display: inline-block;
background: linear-gradient(
90deg,
var(--admin-surface-2) 0%,
color-mix(in srgb, var(--admin-elev) 65%, transparent) 50%,
var(--admin-surface-2) 100%
);
background-size: 200% 100%;
border-radius: 6px;
animation: admin-skeleton-shimmer 1.2s ease-in-out infinite;
}
.admin-skeleton-line {
display: block;
width: 100%;
height: 10px;
border-radius: 4px;
}
.admin-skeleton-line-strong {
height: 12px;
width: 60%;
}
.admin-skeleton-line-soft {
height: 9px;
width: 40%;
opacity: 0.7;
}
.admin-skeleton-line-short {
width: 50%;
}
.admin-skeleton-line-tiny {
width: 32%;
}
.admin-skeleton-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
}
.admin-skeleton-badge {
width: 64px;
height: 18px;
border-radius: 999px;
}
.admin-user-row-skeleton {
cursor: default;
pointer-events: none;
}
.admin-user-row-skeleton .admin-user-main {
display: grid;
gap: 6px;
}
.admin-user-row-skeleton .admin-user-side {
gap: 8px;
}
.admin-table-skeleton td {
padding: 12px 16px;
}
@keyframes admin-skeleton-shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.admin-skeleton {
animation: none;
}
}
.admin-badge { .admin-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -3791,53 +4024,14 @@ a {
cursor: pointer; cursor: pointer;
} }
.admin-switch { .admin-setting-switch {
display: inline-flex; display: inline-flex;
min-height: 36px;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
font-size: 13px;
color: var(--admin-text); color: var(--admin-text);
cursor: pointer; font-size: 12px;
user-select: none; font-weight: 500;
position: relative;
}
.admin-switch-track {
width: 36px;
height: 20px;
border-radius: 999px;
background: var(--admin-elev);
border: 1px solid var(--admin-border);
position: relative;
transition: background 0.18s ease, border-color 0.18s ease;
}
.admin-switch-track::after {
content: "";
position: absolute;
top: 1px;
left: 1px;
width: 16px;
height: 16px;
border-radius: 999px;
background: #fff;
transition: transform 0.18s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}
.admin-switch input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.admin-switch input:checked + .admin-switch-track {
background: color-mix(in srgb, var(--accent) 80%, var(--admin-elev));
border-color: color-mix(in srgb, var(--accent) 50%, transparent);
}
.admin-switch input:checked + .admin-switch-track::after {
transform: translateX(15px);
} }
.admin-tabs { .admin-tabs {
@@ -3941,11 +4135,6 @@ a {
word-break: break-word; word-break: break-word;
} }
.admin-user-link-btn {
align-self: stretch;
justify-content: center;
}
.admin-link-list { .admin-link-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -4011,6 +4200,83 @@ a {
white-space: nowrap; white-space: nowrap;
} }
.admin-traffic-summary {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 10px;
}
.admin-traffic-card {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
padding: 12px;
border: 1px solid var(--admin-border);
border-radius: 10px;
background: color-mix(in srgb, var(--admin-surface-2) 54%, var(--admin-bg));
}
.admin-traffic-card-premium {
border-color: color-mix(in srgb, var(--accent) 30%, var(--admin-border));
}
.admin-traffic-card-warning {
border-color: color-mix(in srgb, #ffb454 46%, var(--admin-border));
}
.admin-traffic-head,
.admin-traffic-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-width: 0;
}
.admin-traffic-head span {
color: var(--admin-muted);
font-size: 12px;
font-weight: 600;
}
.admin-traffic-head strong {
color: var(--admin-text);
font-size: 13px;
font-weight: 700;
text-align: right;
}
.admin-traffic-bar {
height: 8px;
border-radius: 999px;
background: var(--admin-bg);
overflow: hidden;
box-shadow: inset 0 0 0 1px var(--admin-border);
}
.admin-traffic-bar span {
display: block;
height: 100%;
max-width: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--accent), color-mix(in srgb, var(--accent) 55%, #ffffff));
}
.admin-traffic-bar-premium span {
background: linear-gradient(90deg, #66e3ff, var(--accent));
}
.admin-traffic-card-warning .admin-traffic-bar span {
background: linear-gradient(90deg, #ffb454, #ff6b6b);
}
.admin-traffic-meta {
color: var(--admin-muted);
font-size: 11px;
font-weight: 600;
}
@media (min-width: 860px) { @media (min-width: 860px) {
.admin-user-dialog-body { .admin-user-dialog-body {
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
@@ -4459,6 +4725,43 @@ a {
display: none; display: none;
} }
.admin-user-tabs-root {
gap: 0;
}
.admin-user-tabs-root .admin-tabs-list {
gap: 2px;
padding: 4px 4px 0;
border-radius: 10px 10px 0 0;
background: color-mix(in srgb, var(--admin-surface-2) 74%, var(--admin-bg));
border-bottom: 0;
width: fit-content;
max-width: 100%;
}
.admin-user-tabs-root .admin-tabs-trigger {
padding: 8px 14px 9px;
border-radius: 7px 7px 0 0;
}
.admin-user-tabs-root .admin-tabs-trigger[data-state="active"] {
position: relative;
z-index: 1;
box-shadow:
0 -1px 0 var(--admin-border),
1px 0 0 var(--admin-border),
-1px 0 0 var(--admin-border),
0 1px 0 var(--admin-surface);
}
.admin-user-tabs-root .admin-tabs-content {
margin-top: -1px;
padding: 16px;
border: 1px solid var(--admin-border);
border-radius: 0 10px 10px 10px;
background: var(--admin-surface);
}
/* Switch (bits-ui Switch.Root + Thumb) */ /* Switch (bits-ui Switch.Root + Thumb) */
.admin-switch-root { .admin-switch-root {
appearance: none; appearance: none;
@@ -4697,20 +5000,152 @@ a {
.admin-action-grid { .admin-action-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px; gap: 10px;
align-items: end;
}
.admin-action-grid > .admin-btn {
width: 100%;
height: 36px;
align-self: end;
}
.admin-user-quick-actions {
display: grid;
grid-template-columns: minmax(180px, 0.75fr) minmax(260px, 1.25fr);
gap: 12px;
align-items: end;
}
.admin-reset-trial-btn {
width: 100%;
height: 36px;
}
.admin-extend-field {
gap: 6px;
min-width: 0;
}
.admin-action-inline-field {
gap: 6px;
}
.admin-extend-control {
display: grid;
grid-template-columns: minmax(72px, 1fr) auto;
align-items: stretch; align-items: stretch;
min-width: 0;
height: 36px;
border: 1px solid var(--admin-border-strong);
border-radius: 8px;
background: var(--admin-bg);
overflow: hidden;
}
.admin-extend-control .input {
height: 100%;
min-height: 0;
border: 0;
border-radius: 0;
background: transparent;
padding: 0 12px;
line-height: 34px;
box-shadow: none;
}
.admin-extend-control .input:focus {
box-shadow: none;
}
.admin-extend-control .admin-btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 0;
padding: 0 12px;
border-width: 0 0 0 1px;
border-radius: 0;
border-color: var(--admin-border-strong);
background: var(--admin-surface-2);
line-height: 1;
white-space: nowrap;
}
.admin-extend-control .admin-btn svg {
flex: 0 0 auto;
}
.admin-extend-control:focus-within {
border-color: var(--admin-ring);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
} }
.admin-input-row { .admin-input-row {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(72px, 1fr) auto;
gap: 6px; gap: 8px;
align-items: center; align-items: stretch;
min-width: 0;
} }
.admin-input-row .input { .admin-input-row .input,
height: 34px; .admin-input-row .admin-btn {
height: 36px;
}
.admin-actions-tab > .admin-btn {
width: 100%;
height: 36px;
}
.admin-message-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 8px;
}
.admin-message-actions .admin-btn {
height: 36px;
}
.admin-confirm-message-preview {
max-height: min(280px, 45vh);
overflow: auto;
padding: 12px 14px;
border: 1px solid var(--admin-border);
border-radius: 10px;
background: var(--admin-bg);
color: var(--admin-text);
font-size: 13px;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 640px) {
.admin-action-grid,
.admin-user-quick-actions,
.admin-message-actions,
.admin-input-row {
grid-template-columns: minmax(0, 1fr);
}
.admin-extend-control {
grid-template-columns: minmax(0, 1fr);
height: auto;
}
.admin-extend-control .input,
.admin-extend-control .admin-btn {
height: 36px;
}
.admin-extend-control .admin-btn {
border-width: 1px 0 0;
}
} }
/* Two-column variant of admin-form-row */ /* Two-column variant of admin-form-row */
@@ -4815,34 +5250,6 @@ a {
} }
/* Action sections inside the user-detail dialog */
.admin-action-section {
border: 1px solid var(--admin-border);
border-radius: 12px;
background: var(--admin-surface);
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.admin-action-section-head {
display: flex;
flex-direction: column;
gap: 2px;
}
.admin-action-section-head strong {
font-size: 13px;
font-weight: 600;
color: var(--admin-text);
}
.admin-action-section-head small {
color: var(--admin-muted);
font-size: 12px;
}
.admin-danger-zone { .admin-danger-zone {
border: 1px solid color-mix(in srgb, #ff6b6b 32%, transparent); border: 1px solid color-mix(in srgb, #ff6b6b 32%, transparent);
border-radius: 12px; border-radius: 12px;
+100 -1
View File
@@ -790,5 +790,104 @@
"wa_device_topup_for_tariff": "Device packages for {tariff}", "wa_device_topup_for_tariff": "Device packages for {tariff}",
"wa_hwid_devices_package": "+{count} devices", "wa_hwid_devices_package": "+{count} devices",
"wa_no_hwid_device_options": "No device packages available", "wa_no_hwid_device_options": "No device packages available",
"wa_device_topup_options_failed": "Could not load device packages" "wa_device_topup_options_failed": "Could not load device packages",
"admin_nav_overview": "Overview",
"admin_nav_dashboard": "Dashboard",
"admin_nav_operations": "Operations",
"admin_nav_users": "Users",
"admin_nav_payments": "Payments",
"admin_nav_promos": "Promo codes",
"admin_nav_ads": "Ads",
"admin_nav_communication": "Communication",
"admin_nav_broadcast": "Broadcast",
"admin_nav_logs": "Logs",
"admin_nav_system": "System",
"admin_nav_tariffs": "Tariffs",
"admin_nav_settings": "Settings",
"admin_section_stats_title": "Dashboard",
"admin_section_stats_subtitle": "Shop and panel summary",
"admin_section_users_title": "Users",
"admin_section_users_subtitle": "Search, bans, and account actions",
"admin_section_payments_title": "Payments",
"admin_section_payments_subtitle": "Transaction history and export",
"admin_section_promos_title": "Promo codes",
"admin_section_promos_subtitle": "Create and manage codes",
"admin_section_ads_title": "Ad campaigns",
"admin_section_ads_subtitle": "UTM sources and attribution",
"admin_section_broadcast_title": "Broadcast",
"admin_section_broadcast_subtitle": "Bulk Telegram message delivery",
"admin_section_logs_title": "Activity logs",
"admin_section_logs_subtitle": "User events and admin actions",
"admin_section_tariffs_title": "Tariffs",
"admin_section_tariffs_subtitle": "Sales catalog, periods, packages, and limits",
"admin_section_settings_title": "Application settings",
"admin_section_settings_subtitle": "Overrides over .env, applied immediately",
"admin_filter_all": "All",
"admin_filter_not_banned": "Not banned",
"admin_filter_banned": "Banned",
"admin_filter_tg_linked": "With Telegram",
"admin_filter_no_tg": "No Telegram",
"admin_filter_email_linked": "With email",
"admin_filter_no_email": "No email",
"admin_filter_panel_linked": "With panel",
"admin_panel_status": "Panel status",
"admin_panel_status_all": "All statuses",
"admin_sort_registered_desc": "Newest first",
"admin_sort_registered_asc": "Oldest first",
"admin_sort_name_asc": "Name ↑",
"admin_sort_name_desc": "Name ↓",
"admin_sort_id_asc": "ID ↑",
"admin_sort_id_desc": "ID ↓",
"admin_close_menu": "Close menu",
"admin_sidebar_navigation": "Admin navigation",
"admin_exit": "Exit",
"admin_language": "Language",
"admin_menu": "Menu",
"admin_users_search_placeholder": "ID, @username, or email",
"admin_find": "Find",
"admin_filter": "Filter",
"admin_sort": "Sort",
"admin_total": "Total",
"admin_users_empty": "No users found",
"admin_page": "Page",
"admin_page_short": "Page",
"admin_back": "Back",
"admin_next": "Next",
"admin_user": "User",
"admin_amount": "Amount",
"admin_provider": "Provider",
"admin_description": "Description",
"admin_status": "Status",
"admin_date": "Date",
"admin_payments_empty": "No payments",
"admin_logs_user_filter_placeholder": "Filter by user ID",
"admin_apply": "Apply",
"admin_reset": "Reset",
"admin_event": "Event",
"admin_content": "Content",
"admin_logs_empty": "No entries",
"admin_settings_section_general": "General",
"admin_settings_section_appearance": "Appearance",
"admin_settings_section_pricing": "Tariffs and prices",
"admin_settings_section_payments": "Payment systems",
"admin_settings_section_trial": "Trial",
"admin_settings_section_referral": "Referral program",
"admin_settings_section_notifications": "Notifications",
"admin_settings_section_devices": "Devices",
"admin_sync_started": "Synchronization started",
"admin_sync_error": "Synchronization error",
"admin_error": "Error",
"admin_link_copied": "Link copied",
"admin_user_banned": "User banned",
"admin_user_unbanned": "User unbanned",
"admin_message_sent": "Message sent",
"admin_message_send_failed": "Could not send",
"admin_subscription_extended": "Subscription extended by {days} days",
"admin_trial_reset": "Trial reset",
"admin_user_deleted": "User deleted",
"admin_broadcast_target_all": "All active",
"admin_broadcast_target_active": "With subscription",
"admin_broadcast_target_inactive": "No subscription",
"admin_expired_at": "Expired {date}",
"admin_expired_badge": "Expired {date}"
} }
+111 -12
View File
@@ -779,16 +779,115 @@
"wa_traffic_reset_policy": "Стратегия сброса трафика", "wa_traffic_reset_policy": "Стратегия сброса трафика",
"wa_plan_one_year": "1 год", "wa_plan_one_year": "1 год",
"wa_link_email_modal_desc": "Введите email и получите код подтверждения", "wa_link_email_modal_desc": "Введите email и получите код подтверждения",
"buy_hwid_devices_menu_button": "+ HWID ??????????", "buy_hwid_devices_menu_button": "+ HWID устройства",
"buy_hwid_devices_button": "+{count} HWID ?? {price} {currency_symbol}", "buy_hwid_devices_button": "+{count} HWID за {price} {currency_symbol}",
"select_hwid_device_package": "???????? ????? HWID ?????????:", "select_hwid_device_package": "Выберите пакет HWID устройств:",
"choose_payment_method_hwid_devices": "???????? ?????? ?????? ??????? HWID ?????????:", "choose_payment_method_hwid_devices": "Выберите способ оплаты дополнительных HWID устройств:",
"no_hwid_device_packages_available": "??????? HWID ????????? ??? ????? ?????? ?? ?????????.", "no_hwid_device_packages_available": "Дополнительные HWID устройства для этого тарифа не настроены.",
"hwid_devices_unlimited_no_topup": "? ??? ??? ??????????? ????? ?????????.", "hwid_devices_unlimited_no_topup": "У вас уже безлимитное число устройств.",
"payment_description_hwid_devices": "??????? HWID ????????? +{count}", "payment_description_hwid_devices": "Дополнительные HWID устройства +{count}",
"wa_buy_hwid_devices": "???????? ??????????", "wa_buy_hwid_devices": "Купить устройства",
"wa_device_topup_for_tariff": "?????? ????????? ??? ?????? {tariff}", "wa_device_topup_for_tariff": "Пакеты устройств для тарифа {tariff}",
"wa_hwid_devices_package": "+{count} ?????????", "wa_hwid_devices_package": "+{count} устройств",
"wa_no_hwid_device_options": "??? ????????? ??????? ?????????", "wa_no_hwid_device_options": "Нет доступных пакетов устройств",
"wa_device_topup_options_failed": "?? ??????? ????????? ?????? ?????????" "wa_device_topup_options_failed": "Не удалось загрузить пакеты устройств",
"admin_nav_overview": "Overview",
"admin_nav_dashboard": "Дашборд",
"admin_nav_operations": "Управление",
"admin_nav_users": "Пользователи",
"admin_nav_payments": "Платежи",
"admin_nav_promos": "Промокоды",
"admin_nav_ads": "Реклама",
"admin_nav_communication": "Коммуникации",
"admin_nav_broadcast": "Рассылка",
"admin_nav_logs": "Логи",
"admin_nav_system": "Система",
"admin_nav_tariffs": "Тарифы",
"admin_nav_settings": "Настройки",
"admin_section_stats_title": "Дашборд",
"admin_section_stats_subtitle": "Сводка по магазину и панели",
"admin_section_users_title": "Пользователи",
"admin_section_users_subtitle": "Поиск, баны, действия над аккаунтами",
"admin_section_payments_title": "Платежи",
"admin_section_payments_subtitle": "История транзакций и экспорт",
"admin_section_promos_title": "Промокоды",
"admin_section_promos_subtitle": "Создание и управление кодами",
"admin_section_ads_title": "Рекламные кампании",
"admin_section_ads_subtitle": "UTM-источники и атрибуция",
"admin_section_broadcast_title": "Рассылка",
"admin_section_broadcast_subtitle": "Массовая отправка сообщений в Telegram",
"admin_section_logs_title": "Логи активности",
"admin_section_logs_subtitle": "События пользователей и админ-действия",
"admin_section_tariffs_title": "Тарифы",
"admin_section_tariffs_subtitle": "Каталог продаж, периоды, пакеты и лимиты",
"admin_section_settings_title": "Настройки приложения",
"admin_section_settings_subtitle": "Оверрайды над .env, применяются мгновенно",
"admin_filter_all": "Все",
"admin_filter_not_banned": "Не забанены",
"admin_filter_banned": "Забанены",
"admin_filter_tg_linked": "С Telegram",
"admin_filter_no_tg": "Без Telegram",
"admin_filter_email_linked": "С email",
"admin_filter_no_email": "Без email",
"admin_filter_panel_linked": "С панелью",
"admin_panel_status": "Статус панели",
"admin_panel_status_all": "Все статусы",
"admin_sort_registered_desc": "Сначала новые",
"admin_sort_registered_asc": "Сначала старые",
"admin_sort_name_asc": "Имя ↑",
"admin_sort_name_desc": "Имя ↓",
"admin_sort_id_asc": "ID ↑",
"admin_sort_id_desc": "ID ↓",
"admin_close_menu": "Закрыть меню",
"admin_sidebar_navigation": "Навигация админки",
"admin_exit": "Выйти",
"admin_language": "Язык",
"admin_menu": "Меню",
"admin_users_search_placeholder": "ID, @username или email",
"admin_find": "Найти",
"admin_filter": "Фильтр",
"admin_sort": "Сортировка",
"admin_total": "Всего",
"admin_users_empty": "Никого не найдено",
"admin_page": "Страница",
"admin_page_short": "Стр.",
"admin_back": "Назад",
"admin_next": "Далее",
"admin_user": "Пользователь",
"admin_amount": "Сумма",
"admin_provider": "Провайдер",
"admin_description": "Описание",
"admin_status": "Статус",
"admin_date": "Дата",
"admin_payments_empty": "Нет платежей",
"admin_logs_user_filter_placeholder": "Фильтр по ID пользователя",
"admin_apply": "Применить",
"admin_reset": "Сбросить",
"admin_event": "Событие",
"admin_content": "Контент",
"admin_logs_empty": "Записей нет",
"admin_settings_section_general": "Общие",
"admin_settings_section_appearance": "Внешний вид",
"admin_settings_section_pricing": "Тарифы и цены",
"admin_settings_section_payments": "Платёжные системы",
"admin_settings_section_trial": "Триал",
"admin_settings_section_referral": "Реферальная программа",
"admin_settings_section_notifications": "Уведомления",
"admin_settings_section_devices": "Устройства",
"admin_sync_started": "Синхронизация запущена",
"admin_sync_error": "Ошибка синхронизации",
"admin_error": "Ошибка",
"admin_link_copied": "Ссылка скопирована",
"admin_user_banned": "Пользователь забанен",
"admin_user_unbanned": "Пользователь разбанен",
"admin_message_sent": "Сообщение отправлено",
"admin_message_send_failed": "Не удалось отправить",
"admin_subscription_extended": "Подписка продлена на {days} дн.",
"admin_trial_reset": "Триал сброшен",
"admin_user_deleted": "Пользователь удалён",
"admin_broadcast_target_all": "Все активные",
"admin_broadcast_target_active": "С подпиской",
"admin_broadcast_target_inactive": "Без подписки",
"admin_expired_at": "Истекла {date}",
"admin_expired_badge": "Expired {date}"
} }