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
+189 -66
View File
@@ -19,7 +19,7 @@ from typing import Any, Dict, List, Optional
from aiogram import Bot
from aiohttp import web
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.orm import sessionmaker
@@ -163,6 +163,11 @@ def _serialize_user(user: User) -> 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 {
"subscription_id": int(sub.subscription_id),
"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,
"traffic_limit_bytes": sub.traffic_limit_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,
"auto_renew_enabled": bool(sub.auto_renew_enabled),
"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_size = min(100, max(1, int(request.query.get("page_size", 25) or 25)))
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:
if query:
users = await _search_users(session, query, page=page, page_size=page_size)
total = len(users) # cheap upper bound; UI shows hasMore via len==page_size
elif only_banned:
banned = await user_dal.get_banned_users(session)
total = len(banned)
start = page * page_size
users = banned[start : start + 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)
users, total = await _filter_and_sort_users(
session,
query=query,
filter_value=filter_value,
panel_status=panel_status,
sort_value=sort_value,
page=page,
page_size=page_size,
)
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])
@@ -356,7 +370,10 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
serialized = []
for user in users:
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"] = (
f"/api/admin/users/{user.user_id}/avatar?v={cached_avatar_ids[user.user_id]}"
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(
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.
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())
)
rows = (await session.execute(stmt)).all()
out: Dict[int, str] = {}
for uid, panel_status, is_active, _end in rows:
out: Dict[int, Dict[str, Optional[str]]] = {}
for uid, panel_status, is_active, end_date in rows:
if uid in out:
continue
if is_active:
status = (panel_status or "active").lower()
else:
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:
if uid not in out:
out[uid] = "bot_only"
out[uid] = {"status": "bot_only", "end_date": None}
return out
@@ -466,59 +486,118 @@ async def admin_user_avatar_route(request: web.Request) -> web.Response:
return response
async def _search_users(
session: AsyncSession, query: str, *, page: int, page_size: int
) -> List[User]:
"""Best-effort search that tries telegram id, username, email."""
async def _filter_and_sort_users(
session: AsyncSession,
*,
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] = []
seen: set = set()
stmt = select(User)
count_stmt = select(sa_func.count(User.user_id))
if query.isdigit():
user = await user_dal.get_user_by_id(session, int(query))
if user:
candidates.append(user)
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)
search_cond = _user_search_condition(query)
if search_cond is not None:
stmt = stmt.where(search_cond)
count_stmt = count_stmt.where(search_cond)
if "@" in query and not query.startswith("@"):
user = await user_dal.get_user_by_email(session, query)
if user and user.user_id not in seen:
candidates.append(user)
seen.add(user.user_id)
f = (filter_value or "all").lower()
if f == "banned":
cond = User.is_banned.is_(True)
elif f == "active":
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("@")
user = await user_dal.get_user_by_username(session, raw)
if user and user.user_id not in seen:
candidates.append(user)
seen.add(user.user_id)
if cond is not None:
stmt = stmt.where(cond)
count_stmt = count_stmt.where(cond)
if not candidates:
like = f"%{raw}%"
stmt = (
select(User)
.where(
or_(
User.username.ilike(like),
User.first_name.ilike(like),
User.last_name.ilike(like),
User.email.ilike(like),
)
)
.order_by(User.registration_date.desc())
.offset(page * page_size)
.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)
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)
return candidates
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
like = f"%{raw}%"
conditions = [
User.username.ilike(like),
User.first_name.ilike(like),
User.last_name.ilike(like),
User.email.ilike(like),
]
if raw.isdigit():
numeric = int(raw)
conditions.extend([User.user_id == numeric, User.telegram_id == numeric])
return or_(*conditions)
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({})
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:
actor_id = _require_admin_user_id(request)
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_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/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+}/extend", admin_user_extend_route)
router.add_delete("/api/admin/users/{user_id:-?\\d+}", admin_user_delete_route)
+5
View File
@@ -2963,6 +2963,11 @@
logoEmoji={brandEmoji}
appVersion={CFG.appVersion}
appRepositoryUrl={CFG.appRepositoryUrl}
currentLang={currentLang}
languageOptions={languageOptions}
languageBusy={languageBusy}
onLanguageChange={updateAccountLanguage}
t={t}
/>
{:else}
<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;
padding: 9px 10px;
text-align: left;
background: rgba(255, 255, 255, 0.03);
}
.settings-row > svg:first-child {
@@ -2077,7 +2078,7 @@ a {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 60;
z-index: 120;
max-width: min(420px, calc(100vw - 32px));
border: 1px solid color-mix(in srgb, var(--accent) 46%, var(--border));
border-radius: var(--radius);
@@ -2364,10 +2365,7 @@ a {
@media (min-width: 1024px) {
body {
background:
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;
background: #02070b;
}
.app-shell {
@@ -2788,7 +2786,65 @@ a {
font-size: 11px;
color: var(--admin-dim);
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 {
@@ -3014,36 +3070,123 @@ a {
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[type="text"],
.admin-toolbar input[type="number"] {
.admin-toolbar input[type="number"],
.admin-toolbar input[type="search"] {
flex: 1 1 200px;
min-width: 180px;
}
.admin-segmented {
display: inline-flex;
border: 1px solid var(--admin-border);
background: var(--admin-surface);
border-radius: 8px;
padding: 2px;
gap: 2px;
.admin-toolbar > .admin-btn {
height: 36px;
}
.admin-segmented button {
padding: 6px 12px;
border: 0;
background: transparent;
.admin-toolbar-field {
display: grid;
align-items: start;
gap: 6px;
min-width: 0;
}
.admin-toolbar-field-label {
color: var(--admin-muted);
font-size: 12px;
font-size: 11px;
font-weight: 600;
border-radius: 6px;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
.admin-segmented button.active {
background: var(--admin-elev);
.admin-toolbar-select {
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);
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 {
@@ -3528,6 +3671,96 @@ a {
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 {
display: inline-flex;
align-items: center;
@@ -3791,53 +4024,14 @@ a {
cursor: pointer;
}
.admin-switch {
.admin-setting-switch {
display: inline-flex;
min-height: 36px;
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--admin-text);
cursor: pointer;
user-select: none;
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);
font-size: 12px;
font-weight: 500;
}
.admin-tabs {
@@ -3941,11 +4135,6 @@ a {
word-break: break-word;
}
.admin-user-link-btn {
align-self: stretch;
justify-content: center;
}
.admin-link-list {
display: flex;
flex-direction: column;
@@ -4011,6 +4200,83 @@ a {
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) {
.admin-user-dialog-body {
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
@@ -4459,6 +4725,43 @@ a {
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) */
.admin-switch-root {
appearance: none;
@@ -4697,20 +5000,152 @@ a {
.admin-action-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr));
gap: 8px;
grid-template-columns: repeat(2, minmax(0, 1fr));
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;
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 {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px;
align-items: center;
grid-template-columns: minmax(72px, 1fr) auto;
gap: 8px;
align-items: stretch;
min-width: 0;
}
.admin-input-row .input {
height: 34px;
.admin-input-row .input,
.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 */
@@ -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 {
border: 1px solid color-mix(in srgb, #ff6b6b 32%, transparent);
border-radius: 12px;
+100 -1
View File
@@ -790,5 +790,104 @@
"wa_device_topup_for_tariff": "Device packages for {tariff}",
"wa_hwid_devices_package": "+{count} devices",
"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_plan_one_year": "1 год",
"wa_link_email_modal_desc": "Введите email и получите код подтверждения",
"buy_hwid_devices_menu_button": "+ HWID ??????????",
"buy_hwid_devices_button": "+{count} HWID ?? {price} {currency_symbol}",
"select_hwid_device_package": "???????? ????? HWID ?????????:",
"choose_payment_method_hwid_devices": "???????? ?????? ?????? ??????? HWID ?????????:",
"no_hwid_device_packages_available": "??????? HWID ????????? ??? ????? ?????? ?? ?????????.",
"hwid_devices_unlimited_no_topup": "? ??? ??? ??????????? ????? ?????????.",
"payment_description_hwid_devices": "??????? HWID ????????? +{count}",
"wa_buy_hwid_devices": "???????? ??????????",
"wa_device_topup_for_tariff": "?????? ????????? ??? ?????? {tariff}",
"wa_hwid_devices_package": "+{count} ?????????",
"wa_no_hwid_device_options": "??? ????????? ??????? ?????????",
"wa_device_topup_options_failed": "?? ??????? ????????? ?????? ?????????"
"buy_hwid_devices_menu_button": "+ HWID устройства",
"buy_hwid_devices_button": "+{count} HWID за {price} {currency_symbol}",
"select_hwid_device_package": "Выберите пакет HWID устройств:",
"choose_payment_method_hwid_devices": "Выберите способ оплаты дополнительных HWID устройств:",
"no_hwid_device_packages_available": "Дополнительные HWID устройства для этого тарифа не настроены.",
"hwid_devices_unlimited_no_topup": "У вас уже безлимитное число устройств.",
"payment_description_hwid_devices": "Дополнительные HWID устройства +{count}",
"wa_buy_hwid_devices": "Купить устройства",
"wa_device_topup_for_tariff": "Пакеты устройств для тарифа {tariff}",
"wa_hwid_devices_package": "+{count} устройств",
"wa_no_hwid_device_options": "Нет доступных пакетов устройств",
"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}"
}