Загрузить файлы в «/»

This commit is contained in:
2026-07-02 20:28:27 +00:00
parent 33607bae6e
commit b83e07325d
5 changed files with 915 additions and 0 deletions

25
api.js Normal file
View File

@@ -0,0 +1,25 @@
const API = {
async request(path, options = {}) {
const res = await fetch(path, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...options.headers },
...options,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const detail = data.detail;
const msg = Array.isArray(detail)
? detail.map((d) => d.msg).join(', ')
: detail || 'Ошибка запроса';
throw new Error(msg);
}
return data;
},
me: () => API.request('/api/auth/me'),
logout: () => API.request('/api/auth/logout', { method: 'POST' }),
getProfile: () => API.request('/api/profile'),
updateProfile: (body) => API.request('/api/profile', { method: 'PUT', body: JSON.stringify(body) }),
getRobots: () => API.request('/api/robots'),
saveRobots: (body) => API.request('/api/robots', { method: 'PUT', body: JSON.stringify(body) }),
getSessions: () => API.request('/api/sessions'),
};

374
app.js Normal file
View File

@@ -0,0 +1,374 @@
const MODE_LABELS = {
auto: 'Автономный',
manual: 'Ручной',
assist: 'Ассистированный',
standby: 'Ожидание',
};
const STATUS_LABELS = {
online: 'ONLINE',
idle: 'IDLE',
offline: 'OFFLINE',
emergency: 'E-STOP',
};
let currentUser = null;
let ROBOTS = [];
let selectedRobotId = null;
let saveTimer = null;
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
async function init() {
try {
currentUser = await API.me();
} catch {
window.location.href = '/login';
return;
}
$('#header-user').textContent = currentUser.email;
initNavigation();
initLogout();
await loadRobots();
initControls();
initProfile();
initFooterClock();
addLog('Система инициализирована', 'success');
}
async function loadRobots() {
const data = await API.getRobots();
ROBOTS = data.robots;
selectedRobotId = data.selected_robot_id || ROBOTS[0]?.id;
ROBOTS.forEach(applySettingsToRobot);
initRobotList();
selectRobot(selectedRobotId, { skipSave: true });
$('#robot-count').textContent = ROBOTS.length;
}
function applySettingsToRobot(robot) {
if (robot.emergency) {
robot.speed = 0;
robot.status = 'emergency';
robot.temperature = Math.round((robot.baseTemp || 30) + 5);
robot.sensorAccuracy = 0;
return;
}
if (robot.mode === 'standby') {
robot.speed = 0;
robot.status = 'idle';
robot.temperature = Math.round(robot.baseTemp || 30);
robot.sensorAccuracy = Math.round(robot.sensitivity * 0.5);
return;
}
const maxSpeed = robot.maxSpeed || 2;
robot.speed = +(maxSpeed * robot.speedSetting / 100).toFixed(1);
robot.sensorAccuracy = robot.sensitivity;
robot.temperature = Math.round(
(robot.baseTemp || 30) + robot.speedSetting * 0.12 + robot.sensitivity * 0.06
);
if (robot.battery < 15) {
robot.status = 'offline';
robot.speed = 0;
} else {
robot.status = 'online';
}
}
function readControlsToRobot(robot) {
robot.mode = $('#ctrl-mode').value;
robot.speedSetting = parseInt($('#ctrl-speed').value, 10);
robot.sensitivity = parseInt($('#ctrl-sensitivity').value, 10);
robot.emergency = $('#ctrl-emergency').checked;
robot.nightMode = $('#ctrl-night').checked;
}
async function persistRobots() {
await API.saveRobots({
robots: ROBOTS,
selected_robot_id: selectedRobotId,
});
}
function scheduleSave() {
clearTimeout(saveTimer);
saveTimer = setTimeout(async () => {
try {
await persistRobots();
} catch (err) {
showToast(err.message);
}
}, 500);
}
async function saveCurrentRobotSettings({ silent = false } = {}) {
const robot = ROBOTS.find((r) => r.id === selectedRobotId);
if (!robot) return;
readControlsToRobot(robot);
applySettingsToRobot(robot);
refreshRobotListItem(robot);
syncRobotUI(robot);
try {
await persistRobots();
if (!silent) {
addLog(`Настройки применены: ${robot.name}`, 'success');
showToast('Настройки сохранены');
}
} catch (err) {
showToast(err.message);
}
}
function initNavigation() {
$$('.nav__btn').forEach((btn) => {
btn.addEventListener('click', async () => {
const page = btn.dataset.page;
const activePage = $('.page--active')?.id;
if (activePage === 'page-dashboard') {
await saveCurrentRobotSettings({ silent: true });
}
if (activePage === 'page-profile') {
await loadProfile();
}
$$('.nav__btn').forEach((b) => b.classList.remove('nav__btn--active'));
btn.classList.add('nav__btn--active');
$$('.page').forEach((p) => p.classList.remove('page--active'));
$(`#page-${page}`).classList.add('page--active');
if (page === 'profile') await loadProfile();
if (page === 'dashboard') {
const robot = ROBOTS.find((r) => r.id === selectedRobotId);
if (robot) syncRobotUI(robot);
}
});
});
}
function initLogout() {
$('#btn-logout').addEventListener('click', async () => {
await API.logout();
window.location.href = '/login';
});
}
function initRobotList() {
const list = $('#robot-list');
list.innerHTML = ROBOTS.map((r) => renderRobotListItem(r)).join('');
list.querySelectorAll('.robot-item').forEach((item) => {
item.addEventListener('click', async () => {
if (item.dataset.id !== selectedRobotId) {
await saveCurrentRobotSettings({ silent: true });
}
selectRobot(item.dataset.id, { skipSave: true });
});
});
}
function renderRobotListItem(robot) {
const statusClass = robot.status === 'emergency' ? 'emergency' : robot.status;
return `
<div class="robot-item ${robot.id === selectedRobotId ? 'robot-item--active' : ''}" data-id="${robot.id}">
<span class="robot-item__num">${String(robot.number).padStart(3, '0')}</span>
<div class="robot-item__info">
<div class="robot-item__name">${robot.name}</div>
<div class="robot-item__type">${robot.type}</div>
<div class="robot-item__mode">${MODE_LABELS[robot.mode]} · ${robot.speed} м/с</div>
</div>
<span class="robot-item__indicator robot-item__indicator--${statusClass}"></span>
</div>`;
}
function refreshRobotListItem(robot) {
const item = $(`.robot-item[data-id="${robot.id}"]`);
if (!item) return;
const wrapper = document.createElement('div');
wrapper.innerHTML = renderRobotListItem(robot);
const newItem = wrapper.firstElementChild;
item.replaceWith(newItem);
newItem.addEventListener('click', async () => {
if (newItem.dataset.id !== selectedRobotId) {
await saveCurrentRobotSettings({ silent: true });
}
selectRobot(newItem.dataset.id, { skipSave: true });
});
}
function selectRobot(id, { skipSave = false } = {}) {
selectedRobotId = id;
$$('.robot-item').forEach((item) => {
item.classList.toggle('robot-item--active', item.dataset.id === id);
});
const robot = ROBOTS.find((r) => r.id === id);
if (!robot) return;
loadControls(robot);
syncRobotUI(robot);
if (!skipSave) scheduleSave();
addLog(`Выбран робот ${robot.name}`, 'info');
}
function syncRobotUI(robot) {
$('#selected-robot-name').textContent = robot.name;
$('#robot-visual-id').textContent = `${String(robot.number).padStart(3, '0')}`;
$('#robot-mode-label').textContent = MODE_LABELS[robot.mode];
const badge = $('#robot-status-badge');
badge.textContent = STATUS_LABELS[robot.status] || robot.status.toUpperCase();
badge.className = 'badge badge--status';
if (['offline', 'emergency', 'idle'].includes(robot.status)) {
badge.classList.add(robot.status);
}
const frame = $('#robot-visual-frame');
frame.className = 'robot-visual';
if (robot.nightMode) frame.classList.add('robot-visual--night');
if (robot.emergency) frame.classList.add('robot-visual--emergency');
if (robot.mode === 'standby' && !robot.emergency) frame.classList.add('robot-visual--standby');
renderStats(robot);
}
function renderStats(robot) {
const maxSpeed = robot.maxSpeed || 2;
const stats = [
{ label: 'АКБ', value: `${robot.battery}%`, percent: robot.battery },
{ label: 'Скорость', value: `${robot.speed} м/с`, percent: robot.speed / maxSpeed * 100 },
{ label: 'Нагрузка', value: `${robot.payload} кг`, percent: robot.payload / 60 * 100 },
{ label: 'Температура', value: `${robot.temperature}°C`, percent: robot.temperature / 80 * 100 },
{ label: 'Датчики', value: `${robot.sensorAccuracy ?? robot.sensitivity}%`, percent: robot.sensorAccuracy ?? robot.sensitivity },
{ label: 'Режим', value: MODE_LABELS[robot.mode], percent: robot.speedSetting },
];
$('#stats-grid').innerHTML = stats.map((s) => `
<div class="stat-card">
<div class="stat-card__label">${s.label}</div>
<div class="stat-card__value">${s.value}</div>
<div class="stat-card__bar"><div class="stat-card__bar-fill" style="width:${Math.min(s.percent, 100)}%"></div></div>
</div>`).join('');
}
function loadControls(robot) {
$('#ctrl-mode').value = robot.mode;
$('#ctrl-speed').value = robot.speedSetting;
$('#speed-value').textContent = robot.speedSetting;
$('#ctrl-sensitivity').value = robot.sensitivity;
$('#sensitivity-value').textContent = robot.sensitivity;
$('#ctrl-emergency').checked = robot.emergency;
$('#ctrl-night').checked = robot.nightMode;
}
function initControls() {
const onChange = () => {
const robot = ROBOTS.find((r) => r.id === selectedRobotId);
if (!robot) return;
readControlsToRobot(robot);
applySettingsToRobot(robot);
syncRobotUI(robot);
refreshRobotListItem(robot);
scheduleSave();
};
$('#ctrl-mode').addEventListener('change', onChange);
$('#ctrl-emergency').addEventListener('change', onChange);
$('#ctrl-night').addEventListener('change', onChange);
$('#ctrl-speed').addEventListener('input', (e) => {
$('#speed-value').textContent = e.target.value;
onChange();
});
$('#ctrl-sensitivity').addEventListener('input', (e) => {
$('#sensitivity-value').textContent = e.target.value;
onChange();
});
$('#btn-apply').addEventListener('click', () => saveCurrentRobotSettings());
$('#btn-reset').addEventListener('click', async () => {
await loadRobots();
showToast('Настройки сброшены');
});
}
async function loadProfile() {
currentUser = await API.getProfile();
$('#field-email').value = currentUser.email;
$('#field-phone').value = currentUser.phone || '';
$('#field-firstname').value = currentUser.firstname || '';
$('#field-lastname').value = currentUser.lastname || '';
$('#field-patronymic').value = currentUser.patronymic || '';
$('#field-age').value = currentUser.age || '';
$('#profile-email').textContent = currentUser.email;
$('#profile-rating').textContent = currentUser.rating.toFixed(1);
$('#profile-points').textContent = currentUser.points;
updateProfileDisplay();
const { sessions } = await API.getSessions();
$('#sessions-list').innerHTML = sessions.length
? sessions.map((s) => `
<div class="session-item">
<div>Сессия #${s.id}</div>
<div class="session-item__meta">
Создана: ${formatDate(s.created_at)} · Истекает: ${formatDate(s.expires_at)}<br>
IP: ${s.ip_address || '—'} · ${s.user_agent?.slice(0, 60) || '—'}
</div>
</div>`).join('')
: '<p class="session-item">Нет активных сессий</p>';
}
function initProfile() {
$('#profile-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
currentUser = await API.updateProfile({
firstname: $('#field-firstname').value,
lastname: $('#field-lastname').value,
patronymic: $('#field-patronymic').value,
age: parseInt($('#field-age').value, 10) || null,
phone: $('#field-phone').value,
});
updateProfileDisplay();
$('#profile-rating').textContent = currentUser.rating.toFixed(1);
$('#profile-points').textContent = currentUser.points;
addLog('Профиль обновлён', 'success');
showToast('Профиль сохранён');
} catch (err) {
showToast(err.message);
}
});
$('#btn-cancel-profile').addEventListener('click', () => loadProfile());
}
function updateProfileDisplay() {
const parts = [currentUser.lastname, currentUser.firstname, currentUser.patronymic].filter(Boolean);
$('#profile-fullname').textContent = parts.length ? parts.join(' ') : currentUser.email;
}
function formatDate(iso) {
return new Date(iso).toLocaleString('ru-RU');
}
function addLog(message, type = 'info') {
const log = $('#event-log');
const time = new Date().toLocaleTimeString('ru-RU');
const entry = document.createElement('div');
entry.className = `log__entry log__entry--${type}`;
entry.innerHTML = `<div class="log__time">${time}</div><div>${message}</div>`;
log.prepend(entry);
while (log.children.length > 50) log.removeChild(log.lastChild);
}
function showToast(message) {
const toast = $('#toast');
toast.textContent = message;
toast.classList.add('toast--visible');
clearTimeout(showToast._t);
showToast._t = setTimeout(() => toast.classList.remove('toast--visible'), 3000);
}
function initFooterClock() {
const tick = () => { $('#footer-time').textContent = new Date().toLocaleString('ru-RU'); };
tick();
setInterval(tick, 1000);
}
document.addEventListener('DOMContentLoaded', init);

23
auth.js Normal file
View File

@@ -0,0 +1,23 @@
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('auth-form');
const errorEl = document.getElementById('auth-error');
const isRegister = document.body.dataset.page === 'register';
form.addEventListener('submit', async (e) => {
e.preventDefault();
errorEl.textContent = '';
const email = document.getElementById('email').value.trim();
const password = document.getElementById('password').value;
const endpoint = isRegister ? '/api/auth/register' : '/api/auth/login';
try {
await API.request(endpoint, {
method: 'POST',
body: JSON.stringify({ email, password }),
});
window.location.href = '/';
} catch (err) {
errorEl.textContent = err.message;
}
});
});

171
index.html Normal file
View File

@@ -0,0 +1,171 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ROBO-OPS | Панель оператора</title>
<link rel="stylesheet" href="/static/css/styles.css">
</head>
<body>
<header class="header">
<div class="header__brand">
<span class="logo">ROBO-OPS</span>
<span class="header__user" id="header-user"></span>
</div>
<nav class="nav">
<button class="nav__btn nav__btn--active" data-page="dashboard">Главная</button>
<button class="nav__btn" data-page="profile">Личный кабинет</button>
<button class="btn btn--ghost" id="btn-logout">Выйти</button>
</nav>
</header>
<main class="main">
<section id="page-dashboard" class="page page--active">
<div class="page__header">
<h1 class="page__title">Панель управления роботом</h1>
<p class="page__subtitle">Выберите робота и настройте параметры работы</p>
</div>
<div class="dashboard-grid">
<aside class="panel">
<div class="panel__head">
<h2 class="panel__title">Реестр роботов</h2>
<span class="badge" id="robot-count">4</span>
</div>
<div class="robot-list" id="robot-list"></div>
</aside>
<div class="panel panel--wide">
<div class="panel__head">
<h2 class="panel__title">Настройки: <span id="selected-robot-name"></span></h2>
<span class="badge badge--status" id="robot-status-badge"></span>
</div>
<div class="robot-visual" id="robot-visual-frame">
<div class="robot-visual__id" id="robot-visual-id">№ —</div>
<div class="robot-visual__mode" id="robot-mode-label"></div>
</div>
<div class="stats-grid" id="stats-grid"></div>
<div class="controls">
<h3 class="controls__title">Параметры управления</h3>
<div class="control-group">
<label class="control-label">Режим работы
<select id="ctrl-mode" class="control-select">
<option value="auto">Автономный</option>
<option value="manual">Ручной</option>
<option value="assist">Ассистированный</option>
<option value="standby">Ожидание</option>
</select>
</label>
</div>
<div class="control-group">
<label class="control-label">Скорость: <strong id="speed-value">50</strong>%
<input type="range" id="ctrl-speed" min="0" max="100" value="50" class="control-range">
</label>
</div>
<div class="control-group">
<label class="control-label">Чувствительность: <strong id="sensitivity-value">70</strong>%
<input type="range" id="ctrl-sensitivity" min="0" max="100" value="70" class="control-range">
</label>
</div>
<div class="control-group control-group--row">
<label class="checkbox-label"><input type="checkbox" id="ctrl-emergency"> Аварийная остановка</label>
<label class="checkbox-label"><input type="checkbox" id="ctrl-night"> Ночной режим</label>
</div>
<div class="control-actions">
<button class="btn btn--primary" id="btn-apply">Применить</button>
<button class="btn btn--secondary" id="btn-reset">Сбросить</button>
</div>
</div>
</div>
<aside class="panel">
<div class="panel__head">
<h2 class="panel__title">Журнал событий</h2>
</div>
<div class="log" id="event-log"></div>
</aside>
</div>
</section>
<section id="page-profile" class="page">
<div class="page__header">
<h1 class="page__title">Личный кабинет оператора</h1>
<p class="page__subtitle">Профиль, рейтинг и активные сессии</p>
</div>
<div class="profile-layout">
<div class="profile-card panel">
<h2 class="profile-card__name" id="profile-fullname"></h2>
<p class="profile-card__email" id="profile-email"></p>
<div class="profile-stats">
<div class="profile-stat">
<span class="profile-stat__label">Рейтинг</span>
<span class="profile-stat__value" id="profile-rating">0</span>
</div>
<div class="profile-stat">
<span class="profile-stat__label">Баллы</span>
<span class="profile-stat__value" id="profile-points">0</span>
</div>
</div>
</div>
<form class="panel" id="profile-form">
<div class="panel__head">
<h2 class="panel__title">Данные оператора</h2>
</div>
<div class="form-grid">
<div class="form-field">
<label for="field-email">Электронная почта</label>
<input type="email" id="field-email" readonly>
</div>
<div class="form-field">
<label for="field-phone">Номер телефона</label>
<input type="tel" id="field-phone" placeholder="+7 (___) ___-__-__">
</div>
<div class="form-field">
<label for="field-firstname">Имя</label>
<input type="text" id="field-firstname">
</div>
<div class="form-field">
<label for="field-lastname">Фамилия</label>
<input type="text" id="field-lastname">
</div>
<div class="form-field">
<label for="field-patronymic">Отчество</label>
<input type="text" id="field-patronymic">
</div>
<div class="form-field">
<label for="field-age">Возраст</label>
<input type="number" id="field-age" min="18" max="120">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn--primary">Сохранить</button>
<button type="button" class="btn btn--secondary" id="btn-cancel-profile">Отмена</button>
</div>
</form>
<div class="panel profile-sessions">
<div class="panel__head">
<h2 class="panel__title">Активные сессии</h2>
</div>
<div id="sessions-list" class="sessions-list"></div>
</div>
</div>
</section>
</main>
<footer class="footer">
<span>ROBO-OPS — Личный кабинет оператора робота</span>
<span id="footer-time"></span>
</footer>
<div class="toast" id="toast"></div>
<script src="/static/js/api.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>

322
styles.css Normal file
View File

@@ -0,0 +1,322 @@
:root {
--bg: #f5f5f5;
--surface: #fff;
--border: #ddd;
--text: #222;
--text-muted: #666;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--danger: #dc2626;
--success: #16a34a;
--warning: #ca8a04;
--radius: 6px;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
font-size: 14px;
line-height: 1.5;
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.logo { font-weight: 700; font-size: 16px; }
.header__user { margin-left: 16px; color: var(--text-muted); font-size: 13px; }
.nav { display: flex; gap: 8px; align-items: center; }
.nav__btn {
padding: 6px 14px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--radius);
cursor: pointer;
font-size: 13px;
color: var(--text);
}
.nav__btn:hover { border-color: #aaa; }
.nav__btn--active { border-color: var(--accent); color: var(--accent); background: #eff6ff; }
/* Main */
.main { flex: 1; padding: 20px 24px; max-width: 1400px; margin: 0 auto; width: 100%; }
.page { display: none; }
.page--active { display: block; }
.page__header { margin-bottom: 20px; }
.page__title { font-size: 20px; font-weight: 600; margin-bottom: 4px; }
.page__subtitle { color: var(--text-muted); }
/* Panel */
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
}
.panel__head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.panel__title { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); }
.badge {
font-size: 11px;
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-muted);
}
.badge--status { color: var(--success); border-color: #bbf7d0; background: #f0fdf4; }
.badge--status.offline { color: var(--danger); border-color: #fecaca; background: #fef2f2; }
.badge--status.emergency { color: var(--danger); border-color: #fecaca; background: #fef2f2; }
.badge--status.idle { color: var(--warning); border-color: #fef08a; background: #fefce8; }
/* Dashboard */
.dashboard-grid {
display: grid;
grid-template-columns: 240px 1fr 260px;
gap: 16px;
align-items: start;
}
.robot-list { display: flex; flex-direction: column; gap: 6px; }
.robot-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
}
.robot-item:hover { border-color: #aaa; }
.robot-item--active { border-color: var(--accent); background: #eff6ff; }
.robot-item__num {
font-size: 11px;
font-weight: 700;
color: var(--accent);
min-width: 32px;
text-align: center;
}
.robot-item__name { font-weight: 600; font-size: 13px; }
.robot-item__type, .robot-item__mode { font-size: 11px; color: var(--text-muted); }
.robot-item__indicator {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
}
.robot-item__indicator--online { background: var(--success); }
.robot-item__indicator--offline, .robot-item__indicator--emergency { background: var(--danger); }
.robot-item__indicator--idle { background: var(--warning); }
.robot-visual {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 16px;
text-align: center;
min-height: 80px;
position: relative;
background: #fafafa;
}
.robot-visual--night { background: #1e1b2e; color: #c4b5fd; border-color: #7c3aed; }
.robot-visual--emergency { background: #fef2f2; border-color: var(--danger); }
.robot-visual--standby { background: #fefce8; border-color: var(--warning); }
.robot-visual__id { font-size: 12px; color: var(--text-muted); }
.robot-visual__mode { font-size: 11px; color: var(--text-muted); margin-top: 4px; }
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 16px;
}
.stat-card {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px;
text-align: center;
}
.stat-card__label { font-size: 10px; color: var(--text-muted); text-transform: uppercase; }
.stat-card__value { font-size: 16px; font-weight: 600; margin-top: 2px; }
.stat-card__bar {
height: 3px;
background: #eee;
border-radius: 2px;
margin-top: 6px;
overflow: hidden;
}
.stat-card__bar-fill {
height: 100%;
background: var(--accent);
transition: width 0.3s;
}
.controls__title { font-size: 12px; color: var(--text-muted); margin-bottom: 12px; text-transform: uppercase; }
.control-group { margin-bottom: 12px; }
.control-group--row { display: flex; gap: 20px; }
.control-label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
.control-select, .form-field input {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 14px;
background: var(--surface);
}
.control-select:focus, .form-field input:focus {
outline: none;
border-color: var(--accent);
}
.control-range { width: 100%; margin-top: 4px; }
.checkbox-label { font-size: 13px; display: flex; align-items: center; gap: 6px; cursor: pointer; }
.control-actions, .form-actions { display: flex; gap: 8px; margin-top: 12px; }
/* Buttons */
.btn {
padding: 8px 16px;
border-radius: var(--radius);
font-size: 13px;
cursor: pointer;
border: 1px solid var(--border);
background: var(--surface);
}
.btn--primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn--primary:hover { background: var(--accent-hover); }
.btn--secondary:hover { border-color: #aaa; }
.btn--ghost { background: transparent; }
.btn--block { width: 100%; }
/* Log */
.log { max-height: 400px; overflow-y: auto; }
.log__entry { padding: 6px 8px; border-left: 2px solid var(--border); margin-bottom: 4px; font-size: 12px; }
.log__entry--success { border-left-color: var(--success); }
.log__entry--warning { border-left-color: var(--warning); }
.log__entry--info { border-left-color: var(--accent); }
.log__time { color: var(--text-muted); font-size: 11px; }
/* Profile */
.profile-layout {
display: grid;
grid-template-columns: 280px 1fr;
gap: 16px;
}
.profile-sessions { grid-column: 1 / -1; }
.profile-card__name { font-size: 18px; font-weight: 600; margin-bottom: 4px; }
.profile-card__email { color: var(--text-muted); margin-bottom: 16px; }
.profile-stats { display: flex; gap: 16px; }
.profile-stat { flex: 1; border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; text-align: center; }
.profile-stat__label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; }
.profile-stat__value { font-size: 24px; font-weight: 700; color: var(--accent); }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px; }
.form-field { display: flex; flex-direction: column; gap: 4px; }
.form-field label { font-size: 12px; color: var(--text-muted); }
.sessions-list { display: flex; flex-direction: column; gap: 6px; }
.session-item {
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 12px;
}
.session-item__meta { color: var(--text-muted); margin-top: 4px; }
/* Auth */
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.auth-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 32px;
width: 100%;
max-width: 400px;
}
.auth-card h1 { font-size: 22px; margin-bottom: 4px; }
.auth-subtitle { color: var(--text-muted); margin-bottom: 20px; }
.auth-form { display: flex; flex-direction: column; gap: 12px; }
.auth-link { margin-top: 16px; text-align: center; font-size: 13px; color: var(--text-muted); }
.auth-link a { color: var(--accent); }
.form-error { color: var(--danger); font-size: 13px; min-height: 18px; }
/* Footer */
.footer {
display: flex;
justify-content: space-between;
padding: 12px 24px;
border-top: 1px solid var(--border);
font-size: 12px;
color: var(--text-muted);
background: var(--surface);
}
/* Toast */
.toast {
position: fixed;
bottom: 20px;
right: 20px;
padding: 10px 16px;
background: var(--text);
color: #fff;
border-radius: var(--radius);
font-size: 13px;
opacity: 0;
transform: translateY(10px);
transition: all 0.3s;
pointer-events: none;
}
.toast--visible { opacity: 1; transform: translateY(0); }
@media (max-width: 1000px) {
.dashboard-grid, .profile-layout, .form-grid { grid-template-columns: 1fr; }
.header { flex-direction: column; gap: 10px; align-items: flex-start; }
}