v1.1.0: Полный редизайн фронтенда, усиление безопасности и обновление версии
This commit is contained in:
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 1.0.1
|
||||
VERSION: 1.1.0
|
||||
|
||||
jobs:
|
||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>1.0.1</Version>
|
||||
<Version>1.1.0</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
+2
-2
@@ -18,7 +18,7 @@ services:
|
||||
retries: 10
|
||||
|
||||
bot:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.0.1
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.1.0
|
||||
container_name: gmrelay_bot
|
||||
restart: always
|
||||
network_mode: host
|
||||
@@ -30,7 +30,7 @@ services:
|
||||
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN}"
|
||||
|
||||
web:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:1.0.1
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:1.1.0
|
||||
container_name: gmrelay_web
|
||||
restart: always
|
||||
network_mode: host
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="GM-Relay — панель управления для Мастеров Игры. Управляйте сессиями настольных ролевых игр через Telegram." />
|
||||
<meta name="theme-color" content="#0a0e1a" />
|
||||
<base href="/" />
|
||||
<ResourcePreloader />
|
||||
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
|
||||
<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=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="@Assets["app.css"]" />
|
||||
<link rel="stylesheet" href="@Assets["GmRelay.Web.styles.css"]" />
|
||||
<ImportMap />
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
@inherits LayoutComponentBase
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="page">
|
||||
<div class="sidebar">
|
||||
<aside class="sidebar">
|
||||
<NavMenu />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<div class="top-row px-4">
|
||||
<a href="https://github.com/Toutsu/GmRelayBot" target="_blank">О проекте</a>
|
||||
</div>
|
||||
|
||||
<article class="content px-4">
|
||||
<div class="main-area">
|
||||
<article class="content">
|
||||
@Body
|
||||
</article>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
|
||||
@@ -1,86 +1,39 @@
|
||||
.page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
background-color: #f7f7f7;
|
||||
border-bottom: 1px solid #d6d5d5;
|
||||
justify-content: flex-end;
|
||||
height: 3.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
white-space: nowrap;
|
||||
margin-left: 1.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.top-row ::deep a:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (max-width: 640.98px) {
|
||||
.top-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.page {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
width: var(--sidebar-width);
|
||||
background: linear-gradient(180deg, #0f1629 0%, #1a0a2e 100%);
|
||||
border-right: 1px solid var(--border-color);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.top-row {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.top-row.auth ::deep a:first-child {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.top-row, article {
|
||||
padding-left: 2rem !important;
|
||||
padding-right: 1.5rem !important;
|
||||
}
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform var(--transition-smooth);
|
||||
}
|
||||
|
||||
.main-area {
|
||||
flex: 1;
|
||||
margin-left: var(--sidebar-width);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 1.5rem 2rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* === Error UI === */
|
||||
#blazor-error-ui {
|
||||
color-scheme: light only;
|
||||
background: lightyellow;
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border-color);
|
||||
bottom: 0;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.3);
|
||||
box-sizing: border-box;
|
||||
display: none;
|
||||
left: 0;
|
||||
@@ -88,11 +41,44 @@ main {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
#blazor-error-ui .reload {
|
||||
color: var(--accent-secondary);
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
#blazor-error-ui .dismiss {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
|
||||
/* === Mobile Responsive === */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.main-area {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
.content {
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,73 @@
|
||||
<div class="top-row ps-3 navbar navbar-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="">GM-Relay Web</a>
|
||||
</div>
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<div class="nav-header">
|
||||
<a class="nav-brand" href="">
|
||||
<span class="nav-brand-icon">🎲</span>
|
||||
<span class="nav-brand-text">GM-Relay</span>
|
||||
</a>
|
||||
<button class="nav-toggle" @onclick="ToggleMenu" aria-label="Навигационное меню">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input type="checkbox" title="Навигационное меню" class="navbar-toggler" />
|
||||
|
||||
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
|
||||
<nav class="nav flex-column">
|
||||
<nav class="nav-body @(isOpen ? "open" : "")">
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
||||
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Панель управления
|
||||
<div class="nav-section">
|
||||
<NavLink class="nav-item" href="" Match="NavLinkMatch.All" @onclick="CloseMenu">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||
</svg>
|
||||
Панель управления
|
||||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-item px-3 mt-auto">
|
||||
<div class="nav-link text-light">
|
||||
<span class="bi bi-person-fill" aria-hidden="true"></span> @context.User.Identity?.Name
|
||||
|
||||
<div class="nav-footer">
|
||||
<div class="nav-user">
|
||||
<div class="nav-user-avatar">
|
||||
@(context.User.Identity?.Name?.Substring(0, 1).ToUpper() ?? "?")
|
||||
</div>
|
||||
<span class="nav-user-name">@context.User.Identity?.Name</span>
|
||||
</div>
|
||||
<div class="nav-item px-3">
|
||||
|
||||
<form action="/auth/logout" method="post">
|
||||
<AntiforgeryToken />
|
||||
<button type="submit" class="nav-link btn btn-link text-light text-start w-100 p-0 shadow-none border-0">
|
||||
<span class="bi bi-box-arrow-right" aria-hidden="true"></span> Выйти
|
||||
<button type="submit" class="nav-logout-btn">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1-2 2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
Выйти
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="nav-version">v1.1.0</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="login">
|
||||
<span class="bi bi-person-badge-nav-menu" aria-hidden="true"></span> Войти
|
||||
<div class="nav-section">
|
||||
<NavLink class="nav-item" href="login" @onclick="CloseMenu">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
|
||||
<polyline points="10 17 15 12 10 7"/>
|
||||
<line x1="15" y1="12" x2="3" y2="12"/>
|
||||
</svg>
|
||||
Войти
|
||||
</NavLink>
|
||||
</div>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</nav>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@code {
|
||||
private bool isOpen;
|
||||
|
||||
private void ToggleMenu() => isOpen = !isOpen;
|
||||
private void CloseMenu() => isOpen = false;
|
||||
}
|
||||
|
||||
@@ -1,105 +1,194 @@
|
||||
.navbar-toggler {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
width: 3.5rem;
|
||||
height: 2.5rem;
|
||||
color: white;
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.navbar-toggler:checked {
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
min-height: 3.5rem;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.bi {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-right: 0.75rem;
|
||||
top: -1px;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.bi-house-door-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-plus-square-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-list-nested-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
font-size: 0.9rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-item:first-of-type {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.nav-item:last-of-type {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link {
|
||||
color: #d7d7d7;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
height: 3rem;
|
||||
/* === Nav Header === */
|
||||
.nav-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 3rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-item ::deep a.active {
|
||||
background-color: rgba(255,255,255,0.37);
|
||||
color: white;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link:hover {
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
color: white;
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
.nav-brand-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.nav-brand-text {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
background: var(--accent-gradient);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.nav-toggle {
|
||||
display: none;
|
||||
background: none;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.375rem;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.navbar-toggler:checked ~ .nav-scrollable {
|
||||
display: block;
|
||||
.nav-toggle:hover {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.navbar-toggler {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
/* Never collapse the sidebar for wide screens */
|
||||
display: block;
|
||||
|
||||
/* Allow sidebar to scroll for tall menus */
|
||||
height: calc(100vh - 3.5rem);
|
||||
/* === Nav Body === */
|
||||
.nav-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.75rem 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
padding: 0 0.75rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* === Nav Items === */
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: all var(--transition-normal);
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-item.active,
|
||||
.nav-item ::deep a.active {
|
||||
background: rgba(124, 58, 237, 0.15);
|
||||
color: var(--accent-primary);
|
||||
border: 1px solid rgba(124, 58, 237, 0.2);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* === Nav Footer === */
|
||||
.nav-footer {
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.nav-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.5rem 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.nav-user-avatar {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-gradient);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-user-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-logout-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.875rem;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.nav-logout-btn:hover {
|
||||
background: var(--status-danger-bg);
|
||||
color: var(--status-danger);
|
||||
border-color: rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
.nav-version {
|
||||
text-align: center;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
padding-top: 0.5rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* === Mobile === */
|
||||
@media (max-width: 768px) {
|
||||
.nav-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: fixed;
|
||||
top: 0.75rem;
|
||||
left: 0.75rem;
|
||||
z-index: 200;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.nav-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-body.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.nav-header {
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.nav-body {
|
||||
height: calc(100vh - 4.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,57 +6,65 @@
|
||||
@inject SessionService SessionService
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Редактирование сессии - GM-Relay</PageTitle>
|
||||
<PageTitle>Редактирование сессии — GM-Relay</PageTitle>
|
||||
|
||||
<div class="container mt-4">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Главная</a></li>
|
||||
<li class="breadcrumb-item active">Редактирование сессии</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<div class="page-container">
|
||||
<ul class="gm-breadcrumb animate-fade-in">
|
||||
<li><a href="/">Главная</a></li>
|
||||
<li class="active">Редактирование сессии</li>
|
||||
</ul>
|
||||
|
||||
<h2>Редактирование сессии</h2>
|
||||
<div class="page-header animate-fade-in">
|
||||
<h2>✏️ Редактирование сессии</h2>
|
||||
</div>
|
||||
|
||||
@if (session == null)
|
||||
{
|
||||
<p>Загрузка деталей сессии...</p>
|
||||
<div class="glass-card" style="padding: 2rem;">
|
||||
<div class="skeleton skeleton-text" style="width: 50%; margin-bottom: 1.5rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 100%; height: 2.5rem; margin-bottom: 1.5rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 50%; margin-bottom: 1.5rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 100%; height: 2.5rem; margin-bottom: 1.5rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 30%; height: 2.5rem;"></div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card shadow-sm mt-4">
|
||||
<div class="card-body">
|
||||
<div class="glass-card animate-slide-up" style="max-width: 640px;">
|
||||
<EditForm Model="@model" OnValidSubmit="HandleSubmit">
|
||||
<div class="mb-3">
|
||||
<label class="form-label font-weight-bold">Название игры</label>
|
||||
<InputText @bind-Value="model.Title" class="form-control" placeholder="например, D&D 5e: Dragon's Hoard" />
|
||||
<div class="form-text">Изменение этого поля обновит все сессии в одной группе.</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Название игры</label>
|
||||
<InputText @bind-Value="model.Title" class="gm-form-control" placeholder="например, D&D 5e: Dragon's Hoard" />
|
||||
<div class="gm-form-hint">Изменение этого поля обновит все сессии в одной группе.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label font-weight-bold">Запланированное время (МСК UTC+3)</label>
|
||||
<input type="datetime-local" @bind="model.ScheduledAtLocal" class="form-control" />
|
||||
<div class="form-text">Текущее: @session.ScheduledAt.FormatMoscow()</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Запланированное время (МСК, UTC+3)</label>
|
||||
<input type="datetime-local" @bind="model.ScheduledAtLocal" class="gm-form-control" />
|
||||
<div class="gm-form-hint">Текущее: @session.ScheduledAt.FormatMoscow()</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label font-weight-bold">Ссылка для подключения</label>
|
||||
<InputText @bind-Value="model.JoinLink" class="form-control" placeholder="Ссылка на Discord или VTT" />
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Ссылка для подключения</label>
|
||||
<InputText @bind-Value="model.JoinLink" class="gm-form-control" placeholder="Ссылка на Discord или VTT" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-success" disabled="@isSubmitting">
|
||||
@(isSubmitting ? "Сохранение..." : "Сохранить изменения")
|
||||
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
|
||||
<button type="submit" class="btn-gm btn-gm-success" disabled="@isSubmitting">
|
||||
@(isSubmitting ? "⏳ Сохранение..." : "✅ Сохранить изменения")
|
||||
</button>
|
||||
<button type="button" class="btn-gm btn-gm-outline" @onclick="GoBack">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary ms-2" @onclick="GoBack">Отмена</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger mt-3">@errorMessage</div>
|
||||
<div class="gm-alert gm-alert-danger" style="margin-top: 1rem; max-width: 640px;">
|
||||
⚠️ @errorMessage
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,28 +1,26 @@
|
||||
@page "/Error"
|
||||
@page "/Error"
|
||||
@using System.Diagnostics
|
||||
|
||||
<PageTitle>Ошибка</PageTitle>
|
||||
<PageTitle>Ошибка — GM-Relay</PageTitle>
|
||||
|
||||
<h1 class="text-danger">Ошибка.</h1>
|
||||
<h2 class="text-danger">Произошла ошибка при обработке вашего запроса.</h2>
|
||||
<div class="page-container">
|
||||
<div class="error-page">
|
||||
<div class="error-page-icon">⚠️</div>
|
||||
<h1 class="error-page-title">Произошла ошибка</h1>
|
||||
<p class="error-page-text">При обработке вашего запроса что-то пошло не так. Пожалуйста, попробуйте снова.</p>
|
||||
|
||||
@if (ShowRequestId)
|
||||
{
|
||||
<p>
|
||||
<strong>ID запроса:</strong> <code>@RequestId</code>
|
||||
@if (ShowRequestId)
|
||||
{
|
||||
<p style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
|
||||
ID запроса: @RequestId
|
||||
</p>
|
||||
}
|
||||
}
|
||||
|
||||
<h3>Режим разработки</h3>
|
||||
<p>
|
||||
Переключение на среду <strong>Development</strong> отобразит более подробную информацию о произошедшей ошибке.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Среда Development не должна быть включена для развернутых приложений.</strong>
|
||||
Это может привести к отображению конфиденциальной информации из исключений конечным пользователям.
|
||||
Для локальной отладки включите среду <strong>Development</strong>, установив переменную среды <strong>ASPNETCORE_ENVIRONMENT</strong> в значение <strong>Development</strong>
|
||||
и перезапустите приложение.
|
||||
</p>
|
||||
<a href="/" class="btn-gm btn-gm-primary" style="margin-top: 0.5rem;">
|
||||
← На главную
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code{
|
||||
[CascadingParameter]
|
||||
|
||||
@@ -5,32 +5,43 @@
|
||||
@attribute [Authorize]
|
||||
@inject SessionService SessionService
|
||||
|
||||
<PageTitle>Сессии группы - GM-Relay</PageTitle>
|
||||
<PageTitle>Сессии группы — GM-Relay</PageTitle>
|
||||
|
||||
<div class="container mt-4">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Главная</a></li>
|
||||
<li class="breadcrumb-item active">Сессии группы</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<div class="page-container">
|
||||
<ul class="gm-breadcrumb animate-fade-in">
|
||||
<li><a href="/">Главная</a></li>
|
||||
<li class="active">Сессии группы</li>
|
||||
</ul>
|
||||
|
||||
<h2>Предстоящие игры</h2>
|
||||
<div class="page-header animate-fade-in">
|
||||
<h2>📅 Предстоящие игры</h2>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
@if (sessions == null)
|
||||
{
|
||||
<p>Загрузка сессий...</p>
|
||||
<div class="glass-card" style="padding: 2rem;">
|
||||
<div class="skeleton skeleton-text" style="width: 80%; margin-bottom: 1rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 60%; margin-bottom: 0.75rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 70%; margin-bottom: 0.75rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 50%;"></div>
|
||||
</div>
|
||||
}
|
||||
else if (sessions.Count == 0)
|
||||
{
|
||||
<div class="alert alert-info">Для этой группы не найдено предстоящих сессий.</div>
|
||||
<div class="glass-card">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">🎯</div>
|
||||
<div class="empty-state-title">Нет предстоящих сессий</div>
|
||||
<p class="empty-state-text">Для этой группы пока не запланировано игровых сессий.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead class="table-light">
|
||||
@* Desktop table *@
|
||||
<div class="glass-card session-table-desktop animate-slide-up" style="padding: 0; overflow: hidden;">
|
||||
<table class="gm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Название</th>
|
||||
<th>Время (МСК)</th>
|
||||
@@ -43,22 +54,56 @@
|
||||
@foreach (var session in sessions)
|
||||
{
|
||||
<tr>
|
||||
<td>@session.Title</td>
|
||||
<td style="color: var(--text-primary); font-weight: 500;">@session.Title</td>
|
||||
<td>@session.ScheduledAt.FormatMoscow()</td>
|
||||
<td>
|
||||
<span class="badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
||||
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
||||
</td>
|
||||
<td><a href="@session.JoinLink" target="_blank" class="text-truncate d-inline-block" style="max-width: 150px;">Ссылка</a></td>
|
||||
<td>
|
||||
<a href="/session/edit/@session.Id" class="btn btn-sm btn-outline-secondary">Изменить</a>
|
||||
<a href="@session.JoinLink" target="_blank" rel="noopener noreferrer"
|
||||
style="max-width: 150px; display: inline-block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||
Подключиться ↗
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="font-size: 0.8125rem; padding: 0.375rem 0.75rem;">
|
||||
✏️ Изменить
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@* Mobile cards *@
|
||||
<div class="session-card-mobile stagger-children">
|
||||
@foreach (var session in sessions)
|
||||
{
|
||||
<div class="session-card">
|
||||
<div class="session-card-header">
|
||||
<span class="session-card-title">@session.Title</span>
|
||||
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
||||
</div>
|
||||
<div class="session-card-body">
|
||||
<div class="session-card-row">
|
||||
<span>🕐 Время</span>
|
||||
<span style="color: var(--text-primary);">@session.ScheduledAt.FormatMoscow()</span>
|
||||
</div>
|
||||
<div class="session-card-row">
|
||||
<span>🔗 Ссылка</span>
|
||||
<a href="@session.JoinLink" target="_blank" rel="noopener noreferrer">Подключиться ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-card-actions">
|
||||
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">
|
||||
✏️ Изменить
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
@@ -72,10 +117,12 @@
|
||||
|
||||
private string GetStatusClass(string status) => status switch
|
||||
{
|
||||
SessionStatus.Confirmed => "bg-success",
|
||||
SessionStatus.Cancelled => "bg-danger",
|
||||
SessionStatus.ConfirmationSent => "bg-warning text-dark",
|
||||
_ => "bg-secondary"
|
||||
SessionStatus.Confirmed => "status-success",
|
||||
SessionStatus.Cancelled => "status-danger",
|
||||
SessionStatus.ConfirmationSent => "status-warning",
|
||||
"Recruiting" => "status-info",
|
||||
"RecruitmentClosed" => "status-info",
|
||||
_ => "status-neutral"
|
||||
};
|
||||
|
||||
private string TranslateStatus(string status) => status switch
|
||||
@@ -83,7 +130,7 @@
|
||||
"Recruiting" => "Набор",
|
||||
"RecruitmentClosed" => "Набор закрыт",
|
||||
SessionStatus.Planned => "Запланировано",
|
||||
SessionStatus.ConfirmationSent => "Ждем подтверждения",
|
||||
SessionStatus.ConfirmationSent => "Ждём подтверждения",
|
||||
SessionStatus.Confirmed => "Подтверждено",
|
||||
SessionStatus.Cancelled => "Отменено",
|
||||
_ => status
|
||||
|
||||
@@ -6,45 +6,78 @@
|
||||
@inject SessionService SessionService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
|
||||
<PageTitle>Панель управления - GM-Relay</PageTitle>
|
||||
<PageTitle>Панель управления — GM-Relay</PageTitle>
|
||||
|
||||
<div class="container mt-4">
|
||||
<h2>Добро пожаловать, @userName!</h2>
|
||||
<p class="text-muted">Выберите группу для управления играми.</p>
|
||||
<div class="page-container">
|
||||
<div class="page-header animate-fade-in">
|
||||
<h2>Добро пожаловать, @userName! 👋</h2>
|
||||
<p>Выберите группу для управления игровыми сессиями.</p>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
@if (groups == null)
|
||||
{
|
||||
<p>Загрузка групп...</p>
|
||||
<div class="card-grid">
|
||||
@for (int i = 0; i < 3; i++)
|
||||
{
|
||||
<div class="skeleton skeleton-card"></div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (groups.Count == 0)
|
||||
{
|
||||
<div class="col-12">
|
||||
<div class="card bg-light">
|
||||
<div class="card-body text-center">
|
||||
<p class="mb-0">У вас еще нет зарегистрированных групп. Сначала добавьте бота в группу Telegram!</p>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">🤖</div>
|
||||
<div class="empty-state-title">Нет зарегистрированных групп</div>
|
||||
<p class="empty-state-text">Добавьте бота GM-Relay в свою группу Telegram, чтобы начать управлять игровыми сессиями.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card-grid stagger-children">
|
||||
@foreach (var group in groups)
|
||||
{
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">@group.Name</h5>
|
||||
<p class="card-text text-muted">ID: @group.TelegramChatId</p>
|
||||
<a href="/group/@group.Id" class="btn btn-primary">Посмотреть игры</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass-card group-card">
|
||||
<div class="group-card-icon">🎮</div>
|
||||
<h3 class="group-card-title">@group.Name</h3>
|
||||
<p class="group-card-id">ID: @group.TelegramChatId</p>
|
||||
<a href="/group/@group.Id" class="btn-gm btn-gm-primary" style="width: 100%; justify-content: center; margin-top: auto;">
|
||||
Посмотреть игры →
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.group-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.group-card-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.group-card-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.group-card-id {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-family: 'Courier New', monospace;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
private List<WebGameGroup>? groups;
|
||||
private string userName = "";
|
||||
|
||||
@@ -3,21 +3,21 @@
|
||||
@inject NavigationManager Navigation
|
||||
@inject IConfiguration Configuration
|
||||
|
||||
<PageTitle>Вход - GM-Relay</PageTitle>
|
||||
<PageTitle>Вход — GM-Relay</PageTitle>
|
||||
|
||||
<div class="container">
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-6 text-center">
|
||||
<h3>Панель управления GM-Relay</h3>
|
||||
<p class="text-muted">Пожалуйста, войдите как Мастер Игры для управления сессиями.</p>
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<div class="login-logo">🎲</div>
|
||||
<h1 class="login-title">GM-Relay</h1>
|
||||
<p class="login-subtitle">Войдите через Telegram для управления игровыми сессиями</p>
|
||||
|
||||
<div class="mt-4">
|
||||
@if (Navigation.Uri.Contains("error=auth_failed"))
|
||||
{
|
||||
<div class="alert alert-danger">Ошибка аутентификации. Пожалуйста, попробуйте снова.</div>
|
||||
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1.5rem; justify-content: center;">
|
||||
⚠️ Ошибка аутентификации. Пожалуйста, попробуйте снова.
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Telegram Login Widget *@
|
||||
<div id="telegram-login-container">
|
||||
<script async src="https://telegram.org/js/telegram-widget.js?22"
|
||||
data-telegram-login="@BotUsername"
|
||||
@@ -26,8 +26,6 @@
|
||||
data-request-access="write"></script>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
@page "/not-found"
|
||||
@page "/not-found"
|
||||
@layout MainLayout
|
||||
|
||||
<h3>Не найдено</h3>
|
||||
<p>Извините, страница, которую вы ищете, не существует.</p>
|
||||
<PageTitle>404 — GM-Relay</PageTitle>
|
||||
|
||||
<div class="error-page">
|
||||
<div class="error-page-icon">🔍</div>
|
||||
<h1 class="error-page-title">Страница не найдена</h1>
|
||||
<p class="error-page-text">Извините, страница, которую вы ищете, не существует или была перемещена.</p>
|
||||
<a href="/" class="btn-gm btn-gm-primary">
|
||||
← Вернуться на главную
|
||||
</a>
|
||||
</div>
|
||||
@@ -32,12 +32,17 @@ builder.Services.AddSingleton<ITelegramBotClient>(sp =>
|
||||
return new TelegramBotClient(token);
|
||||
});
|
||||
|
||||
// Add Authentication
|
||||
// Add Authentication with hardened cookie settings
|
||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(options =>
|
||||
{
|
||||
options.LoginPath = "/login";
|
||||
options.AccessDeniedPath = "/access-denied";
|
||||
options.Cookie.HttpOnly = true;
|
||||
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||
options.Cookie.SameSite = SameSiteMode.Strict;
|
||||
options.ExpireTimeSpan = TimeSpan.FromDays(7);
|
||||
options.SlidingExpiration = true;
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
@@ -58,6 +63,16 @@ if (!app.Environment.IsDevelopment())
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
// Security headers middleware
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
|
||||
context.Response.Headers["X-Frame-Options"] = "DENY";
|
||||
context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
|
||||
context.Response.Headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()";
|
||||
await next();
|
||||
});
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseAntiforgery();
|
||||
|
||||
@@ -11,7 +11,8 @@ public sealed record WebSession(Guid Id, Guid GroupId, string Title, DateTime Sc
|
||||
|
||||
public sealed class SessionService(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient bot)
|
||||
ITelegramBotClient bot,
|
||||
ILogger<SessionService> logger)
|
||||
{
|
||||
public async Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId)
|
||||
{
|
||||
@@ -121,9 +122,10 @@ public sealed class SessionService(
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
replyMarkup: renderResult.Markup);
|
||||
}
|
||||
catch (Exception)
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Ignore if message too old or same content
|
||||
// Log but don't throw — message may be too old or have same content
|
||||
logger.LogWarning(ex, "Failed to update batch message {MessageId} in chat {ChatId}", messageId, chatId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,19 +26,18 @@ public sealed class TelegramAuthService(IConfiguration configuration)
|
||||
|
||||
var dataCheckString = string.Join("\n", dataCheckList);
|
||||
|
||||
// 2. Compute Secret Key
|
||||
using var sha256 = SHA256.Create();
|
||||
var secretKey = sha256.ComputeHash(Encoding.UTF8.GetBytes(token));
|
||||
// 2. Compute Secret Key (static method — no IDisposable needed)
|
||||
var secretKey = SHA256.HashData(Encoding.UTF8.GetBytes(token));
|
||||
|
||||
// 3. Compute Hash
|
||||
using var hmac = new HMACSHA256(secretKey);
|
||||
var computedHashBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(dataCheckString));
|
||||
var computedHash = Convert.ToHexString(computedHashBytes).ToLower();
|
||||
// 3. Compute Hash (static method — no IDisposable needed)
|
||||
var computedHashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
|
||||
|
||||
if (computedHash != hash.ToString().ToLower())
|
||||
// 4. Timing-safe comparison to prevent timing attacks
|
||||
var hashBytes = Convert.FromHexString(hash.ToString());
|
||||
if (!CryptographicOperations.FixedTimeEquals(computedHashBytes, hashBytes))
|
||||
return false;
|
||||
|
||||
// 4. Check expiration (auth_date)
|
||||
// 5. Check expiration (auth_date)
|
||||
if (query.TryGetValue("auth_date", out var authDateStr) && long.TryParse(authDateStr, out var authDate))
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
|
||||
+795
-31
@@ -1,60 +1,824 @@
|
||||
/* ============================================
|
||||
GM-Relay Design System v1.1.0
|
||||
Dark RPG Dashboard Theme
|
||||
============================================ */
|
||||
|
||||
/* --- Google Fonts loaded in App.razor --- */
|
||||
|
||||
/* === CSS Custom Properties === */
|
||||
:root {
|
||||
/* Background */
|
||||
--bg-primary: #0a0e1a;
|
||||
--bg-secondary: #111827;
|
||||
--bg-card: rgba(17, 24, 39, 0.7);
|
||||
--bg-card-hover: rgba(24, 33, 54, 0.85);
|
||||
--bg-surface: rgba(255, 255, 255, 0.04);
|
||||
--bg-input: rgba(255, 255, 255, 0.06);
|
||||
|
||||
/* Accent */
|
||||
--accent-primary: #7c3aed;
|
||||
--accent-primary-hover: #6d28d9;
|
||||
--accent-secondary: #06b6d4;
|
||||
--accent-gradient: linear-gradient(135deg, #7c3aed 0%, #06b6d4 100%);
|
||||
--accent-gradient-hover: linear-gradient(135deg, #6d28d9 0%, #0891b2 100%);
|
||||
|
||||
/* Text */
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--text-accent: #a78bfa;
|
||||
|
||||
/* Status */
|
||||
--status-success: #22c55e;
|
||||
--status-success-bg: rgba(34, 197, 94, 0.15);
|
||||
--status-warning: #f59e0b;
|
||||
--status-warning-bg: rgba(245, 158, 11, 0.15);
|
||||
--status-danger: #ef4444;
|
||||
--status-danger-bg: rgba(239, 68, 68, 0.15);
|
||||
--status-info: #06b6d4;
|
||||
--status-info-bg: rgba(6, 182, 212, 0.15);
|
||||
--status-neutral: #64748b;
|
||||
--status-neutral-bg: rgba(100, 116, 139, 0.15);
|
||||
|
||||
/* Border */
|
||||
--border-color: rgba(255, 255, 255, 0.08);
|
||||
--border-glow: rgba(124, 58, 237, 0.4);
|
||||
|
||||
/* Glass */
|
||||
--glass-bg: rgba(255, 255, 255, 0.05);
|
||||
--glass-border: rgba(255, 255, 255, 0.1);
|
||||
--glass-blur: 16px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
--shadow-glow: 0 0 20px rgba(124, 58, 237, 0.2);
|
||||
--shadow-glow-hover: 0 0 30px rgba(124, 58, 237, 0.35);
|
||||
|
||||
/* Radius */
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 24px;
|
||||
|
||||
/* Transition */
|
||||
--transition-fast: 0.15s ease;
|
||||
--transition-normal: 0.25s ease;
|
||||
--transition-smooth: 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar-width: 260px;
|
||||
}
|
||||
|
||||
/* === Reset & Base === */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
html, body {
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a, .btn-link {
|
||||
color: #006bb7;
|
||||
/* === Typography === */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.3;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
color: #fff;
|
||||
background-color: #1b6ec2;
|
||||
border-color: #1861ac;
|
||||
h1 { font-size: 1.875rem; }
|
||||
h2 { font-size: 1.5rem; }
|
||||
h3 { font-size: 1.25rem; }
|
||||
|
||||
h1:focus { outline: none; }
|
||||
|
||||
p { line-height: 1.6; }
|
||||
|
||||
a {
|
||||
color: var(--accent-secondary);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
|
||||
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
|
||||
a:hover {
|
||||
color: #22d3ee;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding-top: 1.1rem;
|
||||
/* === Scrollbar === */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
h1:focus {
|
||||
outline: none;
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.valid.modified:not([type=checkbox]) {
|
||||
outline: 1px solid #26b050;
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.invalid {
|
||||
outline: 1px solid #e50000;
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.validation-message {
|
||||
color: #e50000;
|
||||
/* === Glass Card === */
|
||||
.glass-card {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
transition: all var(--transition-smooth);
|
||||
}
|
||||
|
||||
.blazor-error-boundary {
|
||||
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
|
||||
padding: 1rem 1rem 1rem 3.7rem;
|
||||
.glass-card:hover {
|
||||
background: var(--bg-card-hover);
|
||||
border-color: var(--border-glow);
|
||||
box-shadow: var(--shadow-glow-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* === Buttons === */
|
||||
.btn-gm {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal);
|
||||
text-decoration: none;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.btn-gm-primary {
|
||||
background: var(--accent-gradient);
|
||||
color: white;
|
||||
box-shadow: 0 2px 12px rgba(124, 58, 237, 0.3);
|
||||
}
|
||||
|
||||
.btn-gm-primary:hover {
|
||||
background: var(--accent-gradient-hover);
|
||||
box-shadow: 0 4px 20px rgba(124, 58, 237, 0.45);
|
||||
transform: translateY(-1px);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.blazor-error-boundary::after {
|
||||
content: "An error has occurred."
|
||||
.btn-gm-primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-gm-success {
|
||||
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
|
||||
color: white;
|
||||
box-shadow: 0 2px 12px rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.btn-gm-success:hover {
|
||||
box-shadow: 0 4px 20px rgba(34, 197, 94, 0.45);
|
||||
transform: translateY(-1px);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-gm-outline {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-gm-outline:hover {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--glass-border);
|
||||
}
|
||||
|
||||
.btn-gm-danger {
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-gm-danger:hover {
|
||||
box-shadow: 0 4px 20px rgba(239, 68, 68, 0.4);
|
||||
transform: translateY(-1px);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-gm[disabled],
|
||||
.btn-gm:disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* === Status Badges === */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-badge::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background: var(--status-success-bg);
|
||||
color: var(--status-success);
|
||||
border: 1px solid rgba(34, 197, 94, 0.25);
|
||||
}
|
||||
.status-success::before { background: var(--status-success); box-shadow: 0 0 6px var(--status-success); }
|
||||
|
||||
.status-warning {
|
||||
background: var(--status-warning-bg);
|
||||
color: var(--status-warning);
|
||||
border: 1px solid rgba(245, 158, 11, 0.25);
|
||||
}
|
||||
.status-warning::before { background: var(--status-warning); box-shadow: 0 0 6px var(--status-warning); }
|
||||
|
||||
.status-danger {
|
||||
background: var(--status-danger-bg);
|
||||
color: var(--status-danger);
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
.status-danger::before { background: var(--status-danger); box-shadow: 0 0 6px var(--status-danger); }
|
||||
|
||||
.status-info {
|
||||
background: var(--status-info-bg);
|
||||
color: var(--status-info);
|
||||
border: 1px solid rgba(6, 182, 212, 0.25);
|
||||
}
|
||||
.status-info::before { background: var(--status-info); box-shadow: 0 0 6px var(--status-info); }
|
||||
|
||||
.status-neutral {
|
||||
background: var(--status-neutral-bg);
|
||||
color: var(--status-neutral);
|
||||
border: 1px solid rgba(100, 116, 139, 0.25);
|
||||
}
|
||||
.status-neutral::before { background: var(--status-neutral); }
|
||||
|
||||
/* === Form Controls === */
|
||||
.gm-form-control {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 0.875rem;
|
||||
transition: all var(--transition-normal);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.gm-form-control:focus {
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.15);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.gm-form-control::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.gm-form-label {
|
||||
display: block;
|
||||
margin-bottom: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.gm-form-hint {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.gm-form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
/* Override Blazor InputText styling */
|
||||
.form-control,
|
||||
input[type="text"],
|
||||
input[type="datetime-local"],
|
||||
input[type="email"],
|
||||
input[type="url"],
|
||||
input[type="number"],
|
||||
textarea,
|
||||
select {
|
||||
background: var(--bg-input) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
color: var(--text-primary) !important;
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
font-size: 0.875rem !important;
|
||||
padding: 0.625rem 0.875rem !important;
|
||||
transition: all var(--transition-normal) !important;
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
border-color: var(--accent-primary) !important;
|
||||
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.15) !important;
|
||||
background: rgba(255, 255, 255, 0.08) !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
/* Color scheme for date/time pickers */
|
||||
input[type="datetime-local"]::-webkit-calendar-picker-indicator {
|
||||
filter: invert(0.7);
|
||||
}
|
||||
|
||||
/* === Tables === */
|
||||
.gm-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.gm-table thead th {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.gm-table tbody td {
|
||||
padding: 0.875rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.gm-table tbody tr {
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.gm-table tbody tr:hover {
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.gm-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* === Alerts === */
|
||||
.gm-alert {
|
||||
padding: 0.875rem 1.125rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.gm-alert-info {
|
||||
background: var(--status-info-bg);
|
||||
border: 1px solid rgba(6, 182, 212, 0.2);
|
||||
color: var(--status-info);
|
||||
}
|
||||
|
||||
.gm-alert-danger {
|
||||
background: var(--status-danger-bg);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
color: var(--status-danger);
|
||||
}
|
||||
|
||||
.gm-alert-success {
|
||||
background: var(--status-success-bg);
|
||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
color: var(--status-success);
|
||||
}
|
||||
|
||||
/* === Breadcrumb === */
|
||||
.gm-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 1.5rem 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.gm-breadcrumb li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.gm-breadcrumb li + li::before {
|
||||
content: '›';
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.gm-breadcrumb a {
|
||||
color: var(--text-secondary);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.gm-breadcrumb a:hover {
|
||||
color: var(--accent-secondary);
|
||||
}
|
||||
|
||||
.gm-breadcrumb .active {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* === Loading Skeleton === */
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg,
|
||||
var(--bg-surface) 25%,
|
||||
rgba(255, 255, 255, 0.08) 50%,
|
||||
var(--bg-surface) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 160px;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.skeleton-text {
|
||||
height: 1rem;
|
||||
width: 60%;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.skeleton-text-sm {
|
||||
height: 0.75rem;
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
/* === Empty State === */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state-text {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
/* === Page Container === */
|
||||
.page-container {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* === Grid === */
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* === Animations === */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% { box-shadow: 0 0 15px rgba(124, 58, 237, 0.15); }
|
||||
50% { box-shadow: 0 0 25px rgba(124, 58, 237, 0.3); }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.4s ease-out both;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.5s cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||
}
|
||||
|
||||
/* Stagger children animation */
|
||||
.stagger-children > * {
|
||||
animation: fadeIn 0.4s ease-out both;
|
||||
}
|
||||
.stagger-children > *:nth-child(1) { animation-delay: 0.05s; }
|
||||
.stagger-children > *:nth-child(2) { animation-delay: 0.1s; }
|
||||
.stagger-children > *:nth-child(3) { animation-delay: 0.15s; }
|
||||
.stagger-children > *:nth-child(4) { animation-delay: 0.2s; }
|
||||
.stagger-children > *:nth-child(5) { animation-delay: 0.25s; }
|
||||
.stagger-children > *:nth-child(6) { animation-delay: 0.3s; }
|
||||
|
||||
/* === Blazor Overrides === */
|
||||
.valid.modified:not([type=checkbox]) {
|
||||
outline: none;
|
||||
border-color: var(--status-success) !important;
|
||||
}
|
||||
|
||||
.invalid {
|
||||
outline: none;
|
||||
border-color: var(--status-danger) !important;
|
||||
}
|
||||
|
||||
.validation-message {
|
||||
color: var(--status-danger);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.blazor-error-boundary {
|
||||
background: var(--status-danger-bg);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
padding: 1rem 1.25rem;
|
||||
color: var(--status-danger);
|
||||
border-radius: var(--radius-md);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.blazor-error-boundary::after {
|
||||
content: "Произошла ошибка при отображении этого компонента."
|
||||
}
|
||||
|
||||
/* Bootstrap overrides for dark theme */
|
||||
.form-label {
|
||||
color: var(--text-secondary) !important;
|
||||
font-size: 0.8125rem !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
.form-text {
|
||||
color: var(--text-muted) !important;
|
||||
font-size: 0.75rem !important;
|
||||
}
|
||||
|
||||
/* === Login page background === */
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-primary);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-page::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(ellipse at 30% 50%, rgba(124, 58, 237, 0.12) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 70% 50%, rgba(6, 182, 212, 0.08) 0%, transparent 50%);
|
||||
animation: bg-drift 20s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes bg-drift {
|
||||
0% { transform: translate(0, 0) rotate(0deg); }
|
||||
100% { transform: translate(-3%, 2%) rotate(3deg); }
|
||||
}
|
||||
|
||||
.login-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 2.5rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin: 1rem;
|
||||
text-align: center;
|
||||
animation: slideUp 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--accent-gradient);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* === Mobile Sessions Cards (instead of table) === */
|
||||
.session-card-mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.session-table-desktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.darker-border-checkbox.form-check-input {
|
||||
border-color: #929292;
|
||||
.session-card-mobile {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.session-card {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
transition: all var(--transition-smooth);
|
||||
}
|
||||
|
||||
.session-card:hover {
|
||||
border-color: var(--border-glow);
|
||||
}
|
||||
|
||||
.session-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.session-card-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.session-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.session-card-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.session-card-actions {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
|
||||
color: var(--bs-secondary-color);
|
||||
text-align: end;
|
||||
/* === 404 / Error Pages === */
|
||||
.error-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
|
||||
text-align: start;
|
||||
.error-page-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-page-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.error-page-text {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9375rem;
|
||||
max-width: 400px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* === Responsive fine-tuning === */
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
padding: 1.5rem;
|
||||
margin: 0.75rem;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user