diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 1a1c5a5..625d6a5 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -1,3 +1,7 @@ +import "@/shared/styles/index.css"; +import "primereact/resources/themes/lara-light-cyan/theme.css"; +import "primereact/resources/primereact.min.css"; +import "primeicons/primeicons.css"; import { PrimeReactProvider } from "primereact/api"; import { Routing } from "./providers/routing/routing"; diff --git a/frontend/src/shared/api/api.service.ts b/frontend/src/shared/api/api.service.ts new file mode 100644 index 0000000..112a5bf --- /dev/null +++ b/frontend/src/shared/api/api.service.ts @@ -0,0 +1,37 @@ +import { apiClient } from "./axios.instance"; +import type { AxiosResponse } from "axios"; + +export interface ApiResponse { + data: T; + message: string; + success: boolean; +} + +class ApiService { + async get(url: string, config?: any): Promise { + const response: AxiosResponse = await apiClient.get(url, config); + return response.data; + } + + async post(url: string, data?: D, config?: any): Promise { + const response: AxiosResponse = await apiClient.post(url, data, config); + return response.data; + } + + async put(url: string, data?: D, config?: any): Promise { + const response: AxiosResponse = await apiClient.put(url, data, config); + return response.data; + } + + async patch(url: string, data?: D, config?: any): Promise { + const response: AxiosResponse = await apiClient.patch(url, data, config); + return response.data; + } + + async delete(url: string, config?: any): Promise { + const response: AxiosResponse = await apiClient.delete(url, config); + return response.data; + } +} + +export const apiService = new ApiService(); diff --git a/frontend/src/shared/api/axios.instance.ts b/frontend/src/shared/api/axios.instance.ts new file mode 100644 index 0000000..853ec64 --- /dev/null +++ b/frontend/src/shared/api/axios.instance.ts @@ -0,0 +1,64 @@ +import axios, { + type AxiosInstance, + type AxiosResponse, + type AxiosError, + type InternalAxiosRequestConfig, +} from "axios"; + +export interface ApiResponse { + data: T; + message?: string; + status: number; +} + +class ApiClient { + private axiosInstance: AxiosInstance; + + constructor() { + this.axiosInstance = axios.create({ + baseURL: "http://194.113.106.59:8080/api/v1", + timeout: 10000, + headers: { + "Content-Type": "application/json", + }, + validateStatus: (status) => { + return status >= 200 && status < 500; + }, + }); + + this.setupInterceptors(); + } + + private setupInterceptors(): void { + this.axiosInstance.interceptors.request.use( + (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => { + return config; + }, + (error: AxiosError): Promise => { + console.error("[Request Error]", error); + return Promise.reject(error); + }, + ); + + this.axiosInstance.interceptors.response.use( + (response: AxiosResponse): AxiosResponse => { + console.log(`[Response] ${response.status} ${response.config.url}`); + return response; + }, + async (error: AxiosError): Promise => { + if (error.response?.status === 401) { + window.location.href = "/auth"; + return Promise.reject(error); + } + + return Promise.reject(error); + }, + ); + } + + public getInstance(): AxiosInstance { + return this.axiosInstance; + } +} + +export const apiClient = new ApiClient().getInstance(); diff --git a/frontend/src/shared/api/hooks/use.api.ts b/frontend/src/shared/api/hooks/use.api.ts new file mode 100644 index 0000000..1f15b55 --- /dev/null +++ b/frontend/src/shared/api/hooks/use.api.ts @@ -0,0 +1,32 @@ +import { useState, useCallback } from "react"; + +export function useApi() { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const request = useCallback( + async (apiCall: () => Promise): Promise => { + setIsLoading(true); + setError(null); + + try { + const result = await apiCall(); + return result; + } catch (err: any) { + const errorMessage = + err.response?.data?.message || err.message || "Произошла ошибка"; + setError(errorMessage); + return undefined; + } finally { + setIsLoading(false); + } + }, + [], + ); + + return { + isLoading, + error, + request, + }; +} diff --git a/frontend/src/shared/api/index.ts b/frontend/src/shared/api/index.ts new file mode 100644 index 0000000..c98f5ac --- /dev/null +++ b/frontend/src/shared/api/index.ts @@ -0,0 +1,2 @@ +export { apiClient } from "./axios.instance"; +export { useApi } from "./hooks/use.api"; diff --git a/frontend/src/shared/api/websocket.service.ts b/frontend/src/shared/api/websocket.service.ts new file mode 100644 index 0000000..08419b9 --- /dev/null +++ b/frontend/src/shared/api/websocket.service.ts @@ -0,0 +1,136 @@ +// shared/api/websocket.service.ts +import { useAgentStore } from "@/components/layout/sidebar/store/agent.store"; +import { useWebSocket, type LogMessage } from "@/shared/hooks/useWebSocket"; +import { useEffect, useRef, useCallback, useMemo } from "react"; + +interface WebSocketServiceProps { + onLogMessage?: (message: LogMessage) => void; +} + +export const useWebSocketService = ({ + onLogMessage, +}: WebSocketServiceProps = {}) => { + const { agents } = useAgentStore(); + const lastFilterRef = useRef<{ hosts: string[]; services: string[] }>({ + hosts: [], + services: [], + }); + + // Токен для аутентификации + const TOKEN = + "H0AB91gb7427xswom0xalJHq7Ked0tLt6F0gOyqw5yMWPDrroOcX8CjPXeD8uzsU"; + + // Получаем выбранные агенты и сервисы синхронно + const getSelectedServices = useCallback(() => { + const selectedServices: string[] = []; + const selectedHosts: string[] = []; + + agents.forEach((agent) => { + agent.services.forEach((service) => { + if (service.isSelected) { + selectedServices.push(service.name); + selectedHosts.push(agent.token); + } + }); + }); + + return { hosts: selectedHosts, services: selectedServices }; + }, [agents]); + + // Формируем URL синхронно + const wsUrl = useMemo(() => { + const { hosts, services } = getSelectedServices(); + const params = new URLSearchParams(); + + if (hosts.length === 0 && services.length === 0) { + params.append("all", "true"); + } else { + hosts.forEach((host) => { + params.append("hosts", host); + }); + services.forEach((service) => { + params.append("services", service); + }); + } + + const queryString = params.toString(); + const url = `${import.meta.env.VITE_WS_URL}/ws?${queryString}`; + + console.log("Generated WebSocket URL:", url); + + return url; + }, [getSelectedServices]); + + const { + isConnected, + isAuthenticated, + lastMessage, + error, + reconnect, + connect: wsConnect, + disconnect: wsDisconnect, + updateFilter, + } = useWebSocket({ + url: wsUrl, + token: TOKEN, + autoConnect: false, // Отключаем авто-подключение, будем управлять вручную + reconnectInterval: 3000, + maxReconnectAttempts: 10, + }); + + // Функция для подключения + const connect = useCallback(() => { + if (!isManualPausedRef.current) { + wsConnect(); + } + }, [wsConnect]); + + // Функция для отключения + const disconnect = useCallback(() => { + wsDisconnect(); + }, [wsDisconnect]); + + // Реф для отслеживания ручной паузы + const isManualPausedRef = useRef(false); + + // Принудительно переподключаемся при изменении URL, если не на паузе + useEffect(() => { + if (wsUrl && !isManualPausedRef.current) { + console.log("URL changed, reconnecting..."); + setTimeout(() => { + reconnect(); + }, 100); + } + }, [wsUrl, reconnect]); + + // Обновляем фильтр при изменении выбранных сервисов + useEffect(() => { + const { hosts, services } = getSelectedServices(); + const currentFilter = { hosts, services }; + + const hasChanged = + JSON.stringify(currentFilter) !== JSON.stringify(lastFilterRef.current); + + if (hasChanged && isConnected && !isManualPausedRef.current) { + console.log("Updating filter:", currentFilter); + updateFilter(hosts, services); + lastFilterRef.current = currentFilter; + } + }, [agents, isConnected, updateFilter, getSelectedServices]); + + // Передаем сообщения в callback + useEffect(() => { + if (lastMessage && onLogMessage) { + onLogMessage(lastMessage); + } + }, [lastMessage, onLogMessage]); + + return { + isConnected: isConnected && isAuthenticated, + isAuthenticated, + error, + selectedCount: getSelectedServices().services.length, + connect, + disconnect, + }; +}; diff --git a/frontend/src/shared/hooks/useWebSocket.ts b/frontend/src/shared/hooks/useWebSocket.ts new file mode 100644 index 0000000..4a8f41a --- /dev/null +++ b/frontend/src/shared/hooks/useWebSocket.ts @@ -0,0 +1,265 @@ +// shared/hooks/useWebSocket.ts +import { useEffect, useRef, useState, useCallback } from "react"; + +export interface LogMessage { + timestamp: string; + service: string; + level: "log" | "info" | "success" | "warn" | "error"; + message: string; + host: string; + attributes?: Record; +} + +interface WebSocketOptions { + url: string; + token?: string; + autoConnect?: boolean; + reconnectInterval?: number; + maxReconnectAttempts?: number; +} + +export const useWebSocket = (options: WebSocketOptions) => { + const { + url, + token, + autoConnect = true, + reconnectInterval = 3000, + maxReconnectAttempts = 5, + } = options; + + const [isConnected, setIsConnected] = useState(false); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [lastMessage, setLastMessage] = useState(null); + const [error, setError] = useState(null); + + const wsRef = useRef(null); + const reconnectAttemptsRef = useRef(0); + const reconnectTimeoutRef = useRef(null); + const urlRef = useRef(url); + const tokenRef = useRef(token); + const authTimeoutRef = useRef(null); + + const disconnect = useCallback(() => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + if (authTimeoutRef.current) { + clearTimeout(authTimeoutRef.current); + authTimeoutRef.current = null; + } + + if (wsRef.current) { + const ws = wsRef.current; + wsRef.current = null; + + if ( + ws.readyState === WebSocket.OPEN || + ws.readyState === WebSocket.CONNECTING + ) { + ws.close(1000, "Disconnecting"); + } + } + + setIsConnected(false); + setIsAuthenticated(false); + }, []); + + const sendAuth = useCallback(() => { + if ( + wsRef.current && + wsRef.current.readyState === WebSocket.OPEN && + tokenRef.current + ) { + const authMessage = { + type: "auth", + payload: { + token: tokenRef.current, + }, + }; + console.log("Sending auth message..."); + wsRef.current.send(JSON.stringify(authMessage)); + + // Set timeout for auth response + authTimeoutRef.current = setTimeout(() => { + if (!isAuthenticated) { + console.error("Auth timeout"); + setError("Authentication timeout"); + disconnect(); + } + }, 5000); + } + }, [isAuthenticated, disconnect]); + + const connect = useCallback(() => { + // Если URL изменился, пересоздаем соединение + if (urlRef.current !== url) { + console.log("URL changed, forcing new connection"); + disconnect(); + urlRef.current = url; + tokenRef.current = token; + } + + // Если уже есть открытое соединение, не создаем новое + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + console.log("WebSocket already connected"); + return; + } + + try { + console.log("Connecting to WebSocket:", url); + + wsRef.current = new WebSocket(url); + + wsRef.current.onopen = () => { + console.log("WebSocket connected, sending auth..."); + setIsConnected(true); + setError(null); + reconnectAttemptsRef.current = 0; + + // Send authentication immediately after connection + sendAuth(); + }; + + wsRef.current.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + console.log("WebSocket message received:", data); + + // Check if it's an auth response + if (data.type === "auth") { + if (data.success) { + console.log("Authentication successful"); + setIsAuthenticated(true); + setError(null); + if (authTimeoutRef.current) { + clearTimeout(authTimeoutRef.current); + } + } else { + console.error("Authentication failed:", data.error); + setError(data.error || "Authentication failed"); + setIsAuthenticated(false); + disconnect(); + } + } else { + // Regular log message + setLastMessage(data); + } + } catch (err) { + console.error("Failed to parse WebSocket message:", err); + } + }; + + wsRef.current.onerror = (event) => { + console.error("WebSocket error:", event); + setError("Connection error"); + }; + + wsRef.current.onclose = (event) => { + console.log( + "WebSocket disconnected, code:", + event.code, + "reason:", + event.reason, + ); + setIsConnected(false); + setIsAuthenticated(false); + + // Если URL изменился, не переподключаемся автоматически + if (urlRef.current !== url) { + console.log("URL changed, will reconnect manually"); + return; + } + + // Не переподключаемся при нормальном закрытии + if (event.code === 1000) { + console.log("Normal closure, not reconnecting"); + return; + } + + // Attempt reconnection + if ( + reconnectAttemptsRef.current < maxReconnectAttempts && + tokenRef.current + ) { + console.log(`Reconnecting in ${reconnectInterval}ms...`); + reconnectTimeoutRef.current = setTimeout(() => { + reconnectAttemptsRef.current++; + connect(); + }, reconnectInterval); + } else if (reconnectAttemptsRef.current >= maxReconnectAttempts) { + setError("Max reconnection attempts reached"); + } + }; + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to connect"); + } + }, [ + url, + token, + reconnectInterval, + maxReconnectAttempts, + disconnect, + sendAuth, + ]); + + const reconnect = useCallback(() => { + console.log("Manual reconnect triggered"); + disconnect(); + setTimeout(() => { + connect(); + }, 100); + }, [disconnect, connect]); + + const sendMessage = useCallback( + (data: any) => { + if ( + wsRef.current && + wsRef.current.readyState === WebSocket.OPEN && + isAuthenticated + ) { + wsRef.current.send(JSON.stringify(data)); + return true; + } else { + console.warn("WebSocket is not authenticated or not connected"); + return false; + } + }, + [isAuthenticated], + ); + + const updateFilter = useCallback( + (hosts: string[], services: string[]) => { + const message = { + type: "filter", + payload: { hosts, services }, + }; + console.log("Sending filter update:", message); + sendMessage(message); + }, + [sendMessage], + ); + + // Подключаемся при монтировании и при изменении URL или токена + useEffect(() => { + if (autoConnect && url && token) { + connect(); + } + + return () => { + disconnect(); + }; + }, [autoConnect, connect, disconnect, url, token]); + + return { + isConnected: isConnected && isAuthenticated, + isAuthenticated, + lastMessage, + error, + connect, + disconnect, + reconnect, + sendMessage, + updateFilter, + }; +}; diff --git a/frontend/src/shared/styles/index.css b/frontend/src/shared/styles/index.css new file mode 100644 index 0000000..755d870 --- /dev/null +++ b/frontend/src/shared/styles/index.css @@ -0,0 +1,4 @@ +@import "tailwindcss"; +@import "./normalize.css"; +@import "./root.css"; +@import "./themes.css"; diff --git a/frontend/src/shared/styles/normalize.css b/frontend/src/shared/styles/normalize.css new file mode 100644 index 0000000..e7c7aac --- /dev/null +++ b/frontend/src/shared/styles/normalize.css @@ -0,0 +1,365 @@ +/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ + +/* Document + ========================================================================== */ + +/** + * 1. Correct the line height in all browsers. + * 2. Prevent adjustments of font size after orientation changes in iOS. + */ + +html { + line-height: 1.15; + /* 1 */ + -webkit-text-size-adjust: 100%; + /* 2 */ +} + +/* Sections + ========================================================================== */ + +/** + * Remove the margin in all browsers. + */ + +body { + overflow-x: hidden; + margin: 0; +} + +/** + * Render the `main` element consistently in IE. + */ + +main { + display: block; +} + +/** + * Correct the font size and margin on `h1` elements within `section` and + * `article` contexts in Chrome, Firefox, and Safari. + */ + +h1 { + font-size: 2em; +} + +/* Grouping content + ========================================================================== */ + +/** + * 1. Add the correct box sizing in Firefox. + * 2. Show the overflow in Edge and IE. + */ + +hr { + box-sizing: content-box; + /* 1 */ + height: 0; + /* 1 */ + overflow: visible; + /* 2 */ +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +pre { + font-family: monospace, monospace; + /* 1 */ + font-size: 1em; + /* 2 */ +} + +/* Text-level semantics + ========================================================================== */ + +/** + * Remove the gray background on active links in IE 10. + */ + +/* a { + background-color: transparent; +} */ + +/** + * 1. Remove the bottom border in Chrome 57- + * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. + */ + +abbr[title] { + border-bottom: none; + /* 1 */ + text-decoration: underline; + /* 2 */ + text-decoration: underline dotted; + /* 2 */ +} + +/** + * Add the correct font weight in Chrome, Edge, and Safari. + */ + +b, +strong { + font-weight: bolder; +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +code, +kbd, +samp { + font-family: monospace, monospace; + /* 1 */ + font-size: 1em; + /* 2 */ +} + +/** + * Add the correct font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` elements from affecting the line height in + * all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* Embedded content + ========================================================================== */ + +/** + * Remove the border on images inside links in IE 10. + */ + +img { + border-style: none; +} + +/* Forms + ========================================================================== */ + +/** + * 1. Change the font styles in all browsers. + * 2. Remove the margin in Firefox and Safari. + */ + +/** + * Show the overflow in IE. + * 1. Show the overflow in Edge. + */ + +button, +input { + /* 1 */ + overflow: visible; + outline: none; +} + +/** + * Remove the inheritance of text transform in Edge, Firefox, and IE. + * 1. Remove the inheritance of text transform in Firefox. + */ + +button, +select { + /* 1 */ + text-transform: none; +} + +/** + * Correct the inability to style clickable types in iOS and Safari. + */ + +button, +[type="button"], +[type="reset"], +[type="submit"] { + -webkit-appearance: button; +} + +/** + * Remove the inner border and padding in Firefox. + */ + +button::-moz-focus-inner, +[type="button"]::-moz-focus-inner, +[type="reset"]::-moz-focus-inner, +[type="submit"]::-moz-focus-inner { + border-style: none; + padding: 0; +} + +/** + * Restore the focus styles unset by the previous rule. + */ + +button:-moz-focusring, +[type="button"]:-moz-focusring, +[type="reset"]:-moz-focusring, +[type="submit"]:-moz-focusring { + outline: 1px dotted ButtonText; +} + +/** + * Correct the padding in Firefox. + */ + +fieldset { + padding: 0.35em 0.75em 0.625em; +} + +/** + * 1. Correct the text wrapping in Edge and IE. + * 2. Correct the color inheritance from `fieldset` elements in IE. + * 3. Remove the padding so developers are not caught out when they zero out + * `fieldset` elements in all browsers. + */ + +legend { + box-sizing: border-box; + /* 1 */ + color: inherit; + /* 2 */ + display: table; + /* 1 */ + max-width: 100%; + /* 1 */ + padding: 0; + /* 3 */ + white-space: normal; + /* 1 */ +} + +/** + * Add the correct vertical alignment in Chrome, Firefox, and Opera. + */ + +progress { + vertical-align: baseline; +} + +/** + * Remove the default vertical scrollbar in IE 10+. + */ + +textarea { + overflow: auto; +} + +/** + * 1. Add the correct box sizing in IE 10. + * 2. Remove the padding in IE 10. + */ + +[type="checkbox"], +[type="radio"] { + box-sizing: border-box; + /* 1 */ + padding: 0; + /* 2 */ +} + +/** + * Correct the cursor style of increment and decrement buttons in Chrome. + */ + +[type="number"]::-webkit-inner-spin-button, +[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +/** + * 1. Correct the odd appearance in Chrome and Safari. + * 2. Correct the outline style in Safari. + */ + +[type="search"] { + -webkit-appearance: textfield; + /* 1 */ + outline-offset: -2px; + /* 2 */ +} + +/** + * Remove the inner padding in Chrome and Safari on macOS. + */ + +[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * 1. Correct the inability to style clickable types in iOS and Safari. + * 2. Change font properties to `inherit` in Safari. + */ + +::-webkit-file-upload-button { + -webkit-appearance: button; + /* 1 */ + font: inherit; + /* 2 */ +} + +/* Interactive + ========================================================================== */ + +/* + * Add the correct display in Edge, IE 10+, and Firefox. + */ + +details { + display: block; +} + +/* + * Add the correct display in all browsers. + */ + +summary { + display: list-item; +} + +/* Misc + ========================================================================== */ + +/** + * Add the correct display in IE 10+. + */ + +template { + display: none; +} + +/** + * Add the correct display in IE 10. + */ + +[hidden] { + display: none; +} diff --git a/frontend/src/shared/styles/root.css b/frontend/src/shared/styles/root.css new file mode 100644 index 0000000..e0312e1 --- /dev/null +++ b/frontend/src/shared/styles/root.css @@ -0,0 +1,307 @@ +/* Дополнительные стили для PrimeReact с вашей темой */ +.p-tabmenu .p-tabmenuitem .p-menuitem-link { + color: var(--text-secondary); + transition: all 0.2s ease; +} + +.p-tabmenu .p-tabmenuitem .p-menuitem-link:not(.p-disabled):hover { + color: var(--text-primary); + background-color: var(--bg-tertiary); +} + +.p-tabmenu .p-tabmenuitem.p-highlight .p-menuitem-link { + color: var(--accent-primary); + border-color: var(--accent-primary); +} + +.p-menubar { + background-color: var(--bg-secondary); + border: none; + border-radius: 0; +} + +.p-menubar .p-menuitem-link { + color: var(--text-secondary); +} + +.p-menubar .p-menuitem-link:hover { + background-color: var(--bg-tertiary); + color: var(--text-primary); +} + +.p-button.p-button-text { + color: var(--text-secondary); +} + +.p-button.p-button-text:hover { + color: var(--text-primary); + background-color: var(--bg-tertiary); +} + +/* ==================== Стили для скроллов ==================== */ + +/* WebKit браузеры (Chrome, Safari, Edge, Opera) */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-tertiary); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb { + background: var(--border-secondary); + border-radius: 4px; + transition: background 0.2s ease; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--accent-primary); +} + +::-webkit-scrollbar-corner { + background: var(--bg-tertiary); +} + +/* Firefox */ +* { + scrollbar-width: thin; + scrollbar-color: var(--border-secondary) var(--bg-tertiary); +} + +/* Для элементов с прокруткой (кастомные классы) */ +.custom-scrollbar { + scrollbar-width: thin; + scrollbar-color: var(--border-secondary) var(--bg-tertiary); +} + +.custom-scrollbar::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: var(--bg-tertiary); + border-radius: 4px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background: var(--border-secondary); + border-radius: 4px; + transition: background 0.2s ease; +} + +.custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: var(--accent-primary); +} + +/* Для горизонтальных скроллов */ +.horizontal-scrollbar { + overflow-x: auto; +} + +.horizontal-scrollbar::-webkit-scrollbar { + height: 6px; +} + +/* Для очень тонких скроллов (например, в таблицах) */ +.thin-scrollbar::-webkit-scrollbar { + width: 4px; + height: 4px; +} + +.thin-scrollbar::-webkit-scrollbar-track { + background: var(--bg-tertiary); + border-radius: 2px; +} + +.thin-scrollbar::-webkit-scrollbar-thumb { + background: var(--border-secondary); + border-radius: 2px; +} + +.thin-scrollbar::-webkit-scrollbar-thumb:hover { + background: var(--accent-primary); +} + +/* Для темных тем - дополнительная стилизация */ +[data-theme="dark"] ::-webkit-scrollbar-track, +[data-theme="nightowl"] ::-webkit-scrollbar-track, +[data-theme="sunset"] ::-webkit-scrollbar-track, +[data-theme="forest"] ::-webkit-scrollbar-track, +[data-theme="ocean"] ::-webkit-scrollbar-track, +[data-theme="coffee"] ::-webkit-scrollbar-track, +[data-theme="midnight"] ::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.05); +} + +[data-theme="dark"] ::-webkit-scrollbar-thumb, +[data-theme="nightowl"] ::-webkit-scrollbar-thumb, +[data-theme="sunset"] ::-webkit-scrollbar-thumb, +[data-theme="forest"] ::-webkit-scrollbar-thumb, +[data-theme="ocean"] ::-webkit-scrollbar-thumb, +[data-theme="coffee"] ::-webkit-scrollbar-thumb, +[data-theme="midnight"] ::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); +} + +[data-theme="dark"] ::-webkit-scrollbar-thumb:hover, +[data-theme="nightowl"] ::-webkit-scrollbar-thumb:hover, +[data-theme="sunset"] ::-webkit-scrollbar-thumb:hover, +[data-theme="forest"] ::-webkit-scrollbar-thumb:hover, +[data-theme="ocean"] ::-webkit-scrollbar-thumb:hover, +[data-theme="coffee"] ::-webkit-scrollbar-thumb:hover, +[data-theme="midnight"] ::-webkit-scrollbar-thumb:hover { + background: var(--accent-primary); +} + +/* Для светлых тем - более контрастные скроллы */ +[data-theme="light"] ::-webkit-scrollbar-track { + background: #f1f5f9; +} + +[data-theme="light"] ::-webkit-scrollbar-thumb { + background: #cbd5e1; +} + +[data-theme="light"] ::-webkit-scrollbar-thumb:hover { + background: var(--accent-primary); +} + +/* Для лавандовой темы */ +[data-theme="lavender"] ::-webkit-scrollbar-track { + background: #e9d5ff; +} + +[data-theme="lavender"] ::-webkit-scrollbar-thumb { + background: #c084fc; +} + +[data-theme="lavender"] ::-webkit-scrollbar-thumb:hover { + background: var(--accent-primary); +} + +/* Для розовой темы */ +[data-theme="rose"] ::-webkit-scrollbar-track { + background: #fecdd3; +} + +[data-theme="rose"] ::-webkit-scrollbar-thumb { + background: #fb7185; +} + +[data-theme="rose"] ::-webkit-scrollbar-thumb:hover { + background: var(--accent-primary); +} + +/* Стили для скролла в текстовых полях и textarea */ +textarea::-webkit-scrollbar { + width: 6px; +} + +textarea::-webkit-scrollbar-track { + background: var(--bg-tertiary); + border-radius: 3px; +} + +textarea::-webkit-scrollbar-thumb { + background: var(--border-secondary); + border-radius: 3px; +} + +textarea::-webkit-scrollbar-thumb:hover { + background: var(--accent-primary); +} + +/* Стили для скролла в выпадающих списках PrimeReact */ +.p-dropdown-panel .p-dropdown-items-wrapper::-webkit-scrollbar { + width: 6px; +} + +.p-dropdown-panel .p-dropdown-items-wrapper::-webkit-scrollbar-track { + background: var(--bg-tertiary); + border-radius: 3px; +} + +.p-dropdown-panel .p-dropdown-items-wrapper::-webkit-scrollbar-thumb { + background: var(--border-secondary); + border-radius: 3px; +} + +.p-dropdown-panel .p-dropdown-items-wrapper::-webkit-scrollbar-thumb:hover { + background: var(--accent-primary); +} + +/* Стили для скролла в таблицах */ +.p-datatable-wrapper::-webkit-scrollbar { + height: 8px; + width: 8px; +} + +.p-datatable-wrapper::-webkit-scrollbar-track { + background: var(--bg-tertiary); + border-radius: 4px; +} + +.p-datatable-wrapper::-webkit-scrollbar-thumb { + background: var(--border-secondary); + border-radius: 4px; +} + +.p-datatable-wrapper::-webkit-scrollbar-thumb:hover { + background: var(--accent-primary); +} + +/* Стили для скролла в модальных окнах */ +.p-dialog .p-dialog-content::-webkit-scrollbar { + width: 6px; +} + +.p-dialog .p-dialog-content::-webkit-scrollbar-track { + background: var(--bg-tertiary); + border-radius: 3px; +} + +.p-dialog .p-dialog-content::-webkit-scrollbar-thumb { + background: var(--border-secondary); + border-radius: 3px; +} + +.p-dialog .p-dialog-content::-webkit-scrollbar-thumb:hover { + background: var(--accent-primary); +} + +/* Стили для скролла в меню */ +.p-menu .p-menu-list::-webkit-scrollbar { + width: 6px; +} + +.p-menu .p-menu-list::-webkit-scrollbar-track { + background: var(--bg-tertiary); + border-radius: 3px; +} + +.p-menu .p-menu-list::-webkit-scrollbar-thumb { + background: var(--border-secondary); + border-radius: 3px; +} + +.p-menu .p-menu-list::-webkit-scrollbar-thumb:hover { + background: var(--accent-primary); +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.animate-pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} diff --git a/frontend/src/shared/styles/themes.css b/frontend/src/shared/styles/themes.css new file mode 100644 index 0000000..6a8db1f --- /dev/null +++ b/frontend/src/shared/styles/themes.css @@ -0,0 +1,245 @@ +[data-theme="light"] { + --bg-primary: #ffffff; + --bg-secondary: #f8fafc; + --bg-tertiary: #f1f5f9; + --text-primary: #1e293b; + --text-secondary: #64748b; + --text-tertiary: #94a3b8; + --border-primary: #e2e8f0; + --border-secondary: #cbd5e1; + --accent-primary: #4f46e5; + --accent-secondary: #6366f1; + --accent-hover: #4338ca; + --shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.08), 0 1px 2px 0 rgba(0, 0, 0, 0.04); + --shadow-lg: + 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +[data-theme="dark"] { + --bg-primary: #0f172a; + --bg-secondary: #1e293b; + --bg-tertiary: #334155; + --text-primary: #f1f5f9; + --text-secondary: #cbd5e1; + --text-tertiary: #94a3b8; + --border-primary: #475569; + --border-secondary: #64748b; + --accent-primary: #5061fc; + --accent-secondary: #4866ff; + --accent-hover: #6366f1; + --shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.25), 0 1px 2px 0 rgba(0, 0, 0, 0.15); + --shadow-lg: + 0 10px 15px -3px rgba(0, 0, 0, 0.25), 0 4px 6px -2px rgba(0, 0, 0, 0.15); +} + +[data-theme="nightowl"] { + --bg-primary: #011627; + --bg-secondary: #0d293e; + --bg-tertiary: #1d3b53; + --text-primary: #d6deeb; + --text-secondary: #b4c7e0; + --text-tertiary: #7c8da5; + --border-primary: #1d3b53; + --border-secondary: #2d4b63; + --accent-primary: #046390; + --accent-secondary: #065783; + --accent-hover: #38bdf8; + --shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.35), 0 1px 2px 0 rgba(0, 0, 0, 0.25); + --shadow-lg: + 0 10px 15px -3px rgba(0, 0, 0, 0.35), 0 4px 6px -2px rgba(0, 0, 0, 0.25); +} + +[data-theme="sunset"] { + --bg-primary: #1c1917; + --bg-secondary: #292524; + --bg-tertiary: #44403c; + --text-primary: #fafaf9; + --text-secondary: #e7e5e4; + --text-tertiary: #a8a29e; + --border-primary: #57534e; + --border-secondary: #78716c; + --accent-primary: #fb923c; + --accent-secondary: #fdba74; + --accent-hover: #f97316; + --shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px 0 rgba(0, 0, 0, 0.2); + --shadow-lg: + 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.2); +} + +[data-theme="forest"] { + --bg-primary: #052e16; + --bg-secondary: #14532d; + --bg-tertiary: #166534; + --text-primary: #f0fdf4; + --text-secondary: #dcfce7; + --text-tertiary: #86efac; + --border-primary: #15803d; + --border-secondary: #16a34a; + --accent-primary: #309254; + --accent-secondary: #2cef74; + --accent-hover: #22c55e; + --shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.35), 0 1px 2px 0 rgba(0, 0, 0, 0.25); + --shadow-lg: + 0 10px 15px -3px rgba(0, 0, 0, 0.35), 0 4px 6px -2px rgba(0, 0, 0, 0.25); +} + +[data-theme="ocean"] { + --bg-primary: #0c4a6e; + --bg-secondary: #155e75; + --bg-tertiary: #0e7490; + --text-primary: #f0fdfd; + --text-secondary: #cffafe; + --text-tertiary: #a5f3fc; + --border-primary: #0891b2; + --border-secondary: #06b6d4; + --accent-primary: #22d3ee; + --accent-secondary: #67e8f9; + --accent-hover: #06b6d4; + --shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px 0 rgba(0, 0, 0, 0.2); + --shadow-lg: + 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.2); +} + +[data-theme="lavender"] { + --bg-primary: #faf5ff; + --bg-secondary: #f3e8ff; + --bg-tertiary: #e9d5ff; + --text-primary: #581c87; + --text-secondary: #7e22ce; + --text-tertiary: #a855f7; + --border-primary: #d8b4fe; + --border-secondary: #c084fc; + --accent-primary: #a855f7; + --accent-secondary: #c084fc; + --accent-hover: #9333ea; + --shadow: + 0 1px 3px 0 rgba(168, 85, 247, 0.15), 0 1px 2px 0 rgba(168, 85, 247, 0.1); + --shadow-lg: + 0 10px 15px -3px rgba(168, 85, 247, 0.15), + 0 4px 6px -2px rgba(168, 85, 247, 0.1); +} + +[data-theme="coffee"] { + --bg-primary: #292524; + --bg-secondary: #44403c; + --bg-tertiary: #57534e; + --text-primary: #fafaf9; + --text-secondary: #e7e5e4; + --text-tertiary: #d6d3d1; + --border-primary: #78716c; + --border-secondary: #a8a29e; + --accent-primary: #d97706; + --accent-secondary: #f59e0b; + --accent-hover: #b45309; + --shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.35), 0 1px 2px 0 rgba(0, 0, 0, 0.25); + --shadow-lg: + 0 10px 15px -3px rgba(0, 0, 0, 0.35), 0 4px 6px -2px rgba(0, 0, 0, 0.25); +} + +[data-theme="midnight"] { + --bg-primary: #0a0a0a; + --bg-secondary: #171717; + --bg-tertiary: #262626; + --text-primary: #fafafa; + --text-secondary: #d4d4d4; + --text-tertiary: #a3a3a3; + --border-primary: #404040; + --border-secondary: #525252; + --accent-primary: #3b82f6; + --accent-secondary: #60a5fa; + --accent-hover: #2563eb; + --shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.4), 0 1px 2px 0 rgba(0, 0, 0, 0.3); + --shadow-lg: + 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px rgba(0, 0, 0, 0.3); +} + +[data-theme="rose"] { + --bg-primary: #fff1f2; + --bg-secondary: #ffe4e6; + --bg-tertiary: #fecdd3; + --text-primary: #881337; + --text-secondary: #be123c; + --text-tertiary: #e11d48; + --border-primary: #fda4af; + --border-secondary: #fb7185; + --accent-primary: #e11d48; + --accent-secondary: #f43f5e; + --accent-hover: #be123c; + --shadow: + 0 1px 3px 0 rgba(225, 29, 72, 0.15), 0 1px 2px 0 rgba(225, 29, 72, 0.1); + --shadow-lg: + 0 10px 15px -3px rgba(225, 29, 72, 0.15), + 0 4px 6px -2px rgba(225, 29, 72, 0.1); +} + +body { + background-color: var(--bg-primary); + color: var(--text-primary); + transition: + background-color 0.3s ease, + color 0.3s ease; +} + +.bg-primary { + background-color: var(--bg-primary); +} + +.bg-secondary { + background-color: var(--bg-secondary); +} + +.bg-tertiary { + background-color: var(--bg-tertiary); +} + +.text-primary { + color: var(--text-primary); +} + +.text-secondary { + color: var(--text-secondary); +} + +.text-tertiary { + color: var(--text-tertiary); +} + +.border-primary { + border-color: var(--border-primary); +} + +.border-secondary { + border-color: var(--border-secondary); +} + +.border-accent { + border-color: var(--accent-primary); +} + +.accent-primary { + background-color: var(--accent-primary); +} + +.accent-secondary { + background-color: var(--accent-secondary); +} + +.text-accent { + color: var(--accent-primary); +} + +.shadow-regular { + box-shadow: var(--shadow); +} + +.shadow-large { + box-shadow: var(--shadow-lg); +} + +.hover-accent:hover { + background-color: var(--accent-hover); +} + +.hover-bg-secondary:hover { + background-color: var(--bg-secondary); +}