From b83e07325d905f55fdfb77c027464cb109ae1390 Mon Sep 17 00:00:00 2001 From: NikitaKu Date: Thu, 2 Jul 2026 20:28:27 +0000 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=B2=20=C2=AB?= =?UTF-8?q?/=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api.js | 25 ++++ app.js | 374 +++++++++++++++++++++++++++++++++++++++++++++++++++++ auth.js | 23 ++++ index.html | 171 ++++++++++++++++++++++++ styles.css | 322 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 915 insertions(+) create mode 100644 api.js create mode 100644 app.js create mode 100644 auth.js create mode 100644 index.html create mode 100644 styles.css diff --git a/api.js b/api.js new file mode 100644 index 0000000..9ffbf12 --- /dev/null +++ b/api.js @@ -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'), +}; diff --git a/app.js b/app.js new file mode 100644 index 0000000..850548c --- /dev/null +++ b/app.js @@ -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 ` +
+ ${String(robot.number).padStart(3, '0')} +
+
${robot.name}
+
${robot.type}
+
${MODE_LABELS[robot.mode]} · ${robot.speed} м/с
+
+ +
`; +} + +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) => ` +
+
${s.label}
+
${s.value}
+
+
`).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) => ` +
+
Сессия #${s.id}
+
+ Создана: ${formatDate(s.created_at)} · Истекает: ${formatDate(s.expires_at)}
+ IP: ${s.ip_address || '—'} · ${s.user_agent?.slice(0, 60) || '—'} +
+
`).join('') + : '

Нет активных сессий

'; +} + +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 = `
${time}
${message}
`; + 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); diff --git a/auth.js b/auth.js new file mode 100644 index 0000000..e5a43ed --- /dev/null +++ b/auth.js @@ -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; + } + }); +}); diff --git a/index.html b/index.html new file mode 100644 index 0000000..c551a9d --- /dev/null +++ b/index.html @@ -0,0 +1,171 @@ + + + + + + ROBO-OPS | Панель оператора + + + +
+
+ + +
+ +
+ +
+
+ + +
+ + +
+
+

Настройки:

+ +
+ +
+
№ —
+
+
+ +
+ +
+

Параметры управления

+
+ +
+
+ +
+
+ +
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+ + +
+
+

+ +
+
+ Рейтинг + 0 +
+
+ Баллы + 0 +
+
+
+ +
+
+

Данные оператора

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+
+

Активные сессии

+
+
+
+
+
+
+ + + +
+ + + + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..c72c75a --- /dev/null +++ b/styles.css @@ -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; } +}