diff --git a/client/public/assets/locales/en.json b/client/public/assets/locales/en.json index 756625db..fdc9b522 100644 --- a/client/public/assets/locales/en.json +++ b/client/public/assets/locales/en.json @@ -52,6 +52,7 @@ "upload": "Optimal up-speed", "download": "Optimal down-speed", "recommendations": "Recommendations", + "optimal_values": "Optimal values", "change_provider": "Change provider", "password": "Change password", "cron": "Set frequency", @@ -64,10 +65,18 @@ "dark_mode": "Dark mode", "theme_switched_light": "Switched to light mode", "theme_switched_dark": "Switched to dark mode", - "info": "About the project", "provider": "About the provider", "integrations": "Integrations" }, + "about": { + "title": "About MySpeed", + "description": "MySpeed is an open-source speedtest analysis software that records your internet speed for up to 30 days.", + "github": "GitHub", + "website": "Website", + "donate": "Donate", + "translate": "Translate", + "made_by": "Made with" + }, "options": { "cron": { "continuous": "Continuous (every minute)", @@ -122,6 +131,7 @@ "paused": "Speedtests are currently paused. Please continue them if you want to do one.", "running": "A speedtest is already running. Please wait a moment.", "admin_login": "Admin Login", + "servers": "Servers", "beta": { "title": "Beta Version", "description": "This feature is still in beta. If you find any bugs, please report them here." @@ -165,7 +175,6 @@ "before": "before" }, "info": { - "credits": "MySpeed is a open source project provided by GNMYT. Leave a star on GitHub or donate to support the project.", "recommendations_error": "You have to do at least 10 tests to get an average. It doesn't matter if the tests were done manually or automatically.", "recommendations_info": "Based on the last 10 tests, it was found that the optimal ping was {{ping}} ms, the download at {{down}} Mbps and the upload at {{up}} Mbps. It is best to orientate yourself on your internet contract and only adopt it if it matches that.", "update": "An update to version {{version}} is available. See the changes and download the update.", @@ -217,11 +226,7 @@ "automatic": "automatically" }, "overview": { - "title": "Test overview of the last {{amount}}", - "1": "24 hours", - "2": "2 days", - "3": "7 days", - "4": "30 days" + "title_range": "Test overview from {{from}} to {{to}}" } }, "errors": { @@ -236,6 +241,7 @@ "invalid_view": "Invalid view" }, "statistics": { + "average": "Average", "overview": { "total_title": "Total tests", "total_description": "The number of executed tests", @@ -268,8 +274,42 @@ "avg": "Average", "down": "Download values", "up": "Upload values" + }, + "hourly": { + "title": "Speed by Time of Day", + "sample_count": "Tests" + }, + "consistency": { + "title": "Connection Stability", + "excellent": "Excellent", + "good": "Good", + "fair": "Fair", + "poor": "Poor", + "ping_variance": "Ping variance" + }, + "aggregation": { + "daily": "Showing daily averages", + "weekly": "Showing weekly averages" } }, + "calendar": { + "select_range": "Select date range", + "select_start": "Select start date", + "select_end": "Select end date", + "from": "From", + "to": "To", + "last_7_days": "Last 7 days", + "last_30_days": "Last 30 days", + "last_90_days": "Last 90 days", + "last_year": "Last year", + "mon": "Mon", + "tue": "Tue", + "wed": "Wed", + "thu": "Thu", + "fri": "Fri", + "sat": "Sat", + "sun": "Sun" + }, "nodes": { "add": "Add server", "create": "Add", @@ -392,5 +432,20 @@ }, "common": { "back_to_top": "Back to top" + }, + "not_found": { + "title": "Page not found", + "description": "The page you're looking for doesn't exist or has been moved.", + "back_home": "Back to Home" + }, + "route_error": { + "title": "Oops!", + "subtitle": "Something went wrong", + "description": "An unexpected error occurred. Please try again or go back to the home page.", + "reload": "Reload Page" + }, + "optimal_values": { + "title": "Optimal Values", + "use_recommended": "Use recommended" } } diff --git a/client/src/App.jsx b/client/src/App.jsx index ef9e4e95..bc3f02b1 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -16,6 +16,7 @@ import {ThemeProvider} from "@/common/contexts/Theme"; import i18n from './i18n'; import Loading from "@/pages/Loading"; import Error from "@/pages/Error"; +import RouteError from "@/pages/RouteError"; import {ToastNotificationProvider} from "@/common/contexts/ToastNotification"; import {NodeProvider} from "@/common/contexts/Node"; import {library} from '@fortawesome/fontawesome-svg-core'; @@ -65,6 +66,7 @@ const App = () => {
), + errorElement: , children: [ {path: "/", element: }, {path: "/nodes", element: }, diff --git a/client/src/common/components/AboutDialog/AboutDialog.jsx b/client/src/common/components/AboutDialog/AboutDialog.jsx new file mode 100644 index 00000000..05fdae74 --- /dev/null +++ b/client/src/common/components/AboutDialog/AboutDialog.jsx @@ -0,0 +1,77 @@ +import {DialogContext, DialogProvider} from "@/common/contexts/Dialog"; +import "./styles.sass"; +import React, {useContext, useEffect, useState} from "react"; +import {t} from "i18next"; +import {Trans} from "react-i18next"; +import {faClose, faGlobe, faHeart, faLanguage} from "@fortawesome/free-solid-svg-icons"; +import {faGithub} from "@fortawesome/free-brands-svg-icons"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {jsonRequest} from "@/common/utils/RequestUtil"; +import {PROJECT_URL, WEB_URL, DONATION_URL} from "@/index"; + +const Dialog = ({version}) => { + const close = useContext(DialogContext); + + const links = [ + {icon: faGithub, label: t("about.github"), url: PROJECT_URL}, + {icon: faGlobe, label: t("about.website"), url: WEB_URL}, + {icon: faHeart, label: t("about.donate"), url: DONATION_URL, accent: true}, + {icon: faLanguage, label: t("about.translate"), url: "https://crowdin.com/project/myspeed"} + ]; + + return ( + <> +
+

{t("about.title")}

+ close()}/> +
+ +
+
+ MySpeed Logo +
+

MySpeed

+ v{version} +
+
+ +

+ + MySpeed is an open-source speedtest analysis software that records your internet speed for up to 30 days. + +

+ +
+ {links.map((link, index) => ( + + + {link.label} + + ))} +
+ +

+ {t("about.made_by")} by GNMYT +

+
+ + ); +}; + +export const AboutDialog = (props) => { + const [version, setVersion] = useState(null); + + useEffect(() => { + jsonRequest("/info/version").then(data => setVersion(data.local)); + }, []); + + return ( + + {!version && ( +
+ )} + {version && } + + ); +}; diff --git a/client/src/common/components/AboutDialog/index.js b/client/src/common/components/AboutDialog/index.js new file mode 100644 index 00000000..47de988d --- /dev/null +++ b/client/src/common/components/AboutDialog/index.js @@ -0,0 +1 @@ +export {AboutDialog as default} from "./AboutDialog.jsx"; \ No newline at end of file diff --git a/client/src/common/components/AboutDialog/styles.sass b/client/src/common/components/AboutDialog/styles.sass new file mode 100644 index 00000000..351b7e62 --- /dev/null +++ b/client/src/common/components/AboutDialog/styles.sass @@ -0,0 +1,104 @@ +@use "@/common/styles/colors" as * + +.about-dialog + max-width: 26rem + +.about-wrapper + display: flex + flex-direction: column + gap: 1rem + margin-top: 1rem + +.about-hero + display: flex + align-items: center + gap: 1rem + +.about-logo + width: 56px + height: 56px + +.about-title-section + display: flex + flex-direction: column + gap: 0.15rem + +.about-title + margin: 0 + font-size: 1.5rem + font-weight: 700 + color: $white + +.about-version + font-size: 0.85rem + color: $accent-primary + font-weight: 600 + +.about-description + margin: 0 + font-size: 0.9rem + color: $subtext + line-height: 1.5 + +.about-links + display: grid + grid-template-columns: 1fr 1fr + gap: 0.5rem + +.about-link + display: flex + align-items: center + gap: 0.6rem + padding: 0.6rem 0.85rem + border: 1px solid $light-gray + border-radius: 0.5rem + color: $white + text-decoration: none + font-size: 0.85rem + font-weight: 500 + transition: all 0.2s ease + + svg + color: $subtext + font-size: 1rem + + &:hover + background: $darker-gray + + svg + color: $white + +.about-link-accent + svg + color: $accent-danger + + &:hover + svg + color: $accent-danger + +.about-footer + margin: 0 + padding-top: 0.5rem + font-size: 0.8rem + color: $subtext + text-align: center + border-top: 1px solid $light-gray + + a + color: $accent-primary + text-decoration: none + + &:hover + text-decoration: underline + +.about-heart + color: $accent-danger + font-size: 0.7rem + margin: 0 0.1rem + +@media (max-width: 500px) + .about-dialog + max-width: 90vw + + .about-links + grid-template-columns: 1fr diff --git a/client/src/common/components/ChartModal/ChartModal.jsx b/client/src/common/components/ChartModal/ChartModal.jsx new file mode 100644 index 00000000..027a8d67 --- /dev/null +++ b/client/src/common/components/ChartModal/ChartModal.jsx @@ -0,0 +1,44 @@ +import { useEffect, useCallback } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faXmark } from "@fortawesome/free-solid-svg-icons"; +import "./styles.sass"; + +export const ChartModal = ({ isOpen, onClose, isChart = false, children }) => { + const handleEscape = useCallback((e) => { + if (e.key === "Escape") { + onClose(); + } + }, [onClose]); + + useEffect(() => { + if (isOpen) { + document.addEventListener("keydown", handleEscape); + document.body.style.overflow = "hidden"; + } + return () => { + document.removeEventListener("keydown", handleEscape); + document.body.style.overflow = ""; + }; + }, [isOpen, handleEscape]); + + if (!isOpen) return null; + + const handleBackdropClick = (e) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + return ( +
+
+ +
+ {children} +
+
+
+ ); +}; \ No newline at end of file diff --git a/client/src/common/components/ChartModal/index.js b/client/src/common/components/ChartModal/index.js new file mode 100644 index 00000000..5b5d1cbe --- /dev/null +++ b/client/src/common/components/ChartModal/index.js @@ -0,0 +1 @@ +export { ChartModal as default } from "./ChartModal"; diff --git a/client/src/common/components/ChartModal/styles.sass b/client/src/common/components/ChartModal/styles.sass new file mode 100644 index 00000000..cf6f0fdb --- /dev/null +++ b/client/src/common/components/ChartModal/styles.sass @@ -0,0 +1,156 @@ +@use "@/common/styles/colors" as * + +.chart-modal-backdrop + position: fixed + top: 0 + left: 0 + right: 0 + bottom: 0 + background-color: rgba(0, 0, 0, 0.85) + display: flex + align-items: center + justify-content: center + z-index: 1000 + padding: 2rem + animation: modalFadeIn 0.25s ease-out + backdrop-filter: blur(4px) + +.chart-modal-content + position: relative + width: fit-content + min-width: 400px + max-width: 1400px + max-height: 90vh + background-color: $dark-gray + backdrop-filter: blur(4px) + -webkit-backdrop-filter: blur(4px) + border: 1px solid $light-gray + border-radius: 1rem + overflow: hidden + animation: scaleIn 0.25s ease-out + box-shadow: 0 24px 48px rgba(0, 0, 0, 0.4) + + &.modal-chart + width: 100% + +.chart-modal-close + position: absolute + top: 1rem + right: 1rem + background: $darker-gray + border: 1px solid $light-gray + border-radius: 0.5rem + color: $subtext + width: 2.5rem + height: 2.5rem + display: flex + align-items: center + justify-content: center + cursor: pointer + transition: all 0.2s ease + z-index: 10 + font-size: 1.25rem + + &:hover + background: $light-gray + color: $white + border-color: $accent-primary + +.chart-modal-body + padding: 1.5rem + padding-top: 3.5rem + + .chart-container, + .stats-container + width: 100% + border: none + transform: none !important + cursor: default + box-shadow: none + + &:hover + border: none + transform: none !important + box-shadow: none + + &.modal-body-chart + .chart-container + min-height: 70vh + + .chart-body + min-height: 60vh + + .stats-content + height: auto + min-height: auto + + .consistency-container + gap: 2rem + + .consistency-item + svg + width: 3rem + height: 3rem + .consistency-info + h2 + font-size: 1.25rem + p + font-size: 1.25rem + .consistency-detail + font-size: 0.95rem + + .overview-items + gap: 1.5rem + + .overview-item + .info-area + svg + width: 3rem + height: 3rem + .text-area + h2 + font-size: 1.25rem + p + font-size: 0.9rem + h2 + font-size: 1.75rem + + .info-container + gap: 2rem + + .test-container + svg + width: 3rem + height: 3rem + .test-info + h2 + font-size: 1.25rem + p + font-size: 1.1rem + + .value-container + gap: 2rem + + .value-item + svg + width: 3rem + height: 3rem + .value-info + h2 + font-size: 1.25rem + p + font-size: 1.1rem + +@keyframes modalFadeIn + from + opacity: 0 + to + opacity: 1 + +@keyframes scaleIn + from + opacity: 0 + transform: scale(0.96) + to + opacity: 1 + transform: scale(1) diff --git a/client/src/common/components/DateRangePicker/DateRangePicker.jsx b/client/src/common/components/DateRangePicker/DateRangePicker.jsx new file mode 100644 index 00000000..3807e0d8 --- /dev/null +++ b/client/src/common/components/DateRangePicker/DateRangePicker.jsx @@ -0,0 +1,249 @@ +import { useState, useRef, useEffect, useMemo } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faCalendar, faChevronLeft, faChevronRight } from "@fortawesome/free-solid-svg-icons"; +import { t } from "i18next"; +import "./styles.sass"; + +export const DateRangePicker = ({ from, to, onChange, minDate, maxDate }) => { + const [isOpen, setIsOpen] = useState(false); + const [selecting, setSelecting] = useState("from"); + const [tempFrom, setTempFrom] = useState(from); + const [tempTo, setTempTo] = useState(to); + const [hoverDate, setHoverDate] = useState(null); + const [currentMonth, setCurrentMonth] = useState(new Date(to || new Date())); + const popoverRef = useRef(null); + const triggerRef = useRef(null); + + const today = new Date(); + today.setHours(23, 59, 59, 999); + const effectiveMaxDate = maxDate || today; + const todayDateString = new Date().toDateString(); + + useEffect(() => { + const handleClickOutside = (event) => { + if (popoverRef.current && !popoverRef.current.contains(event.target) && + triggerRef.current && !triggerRef.current.contains(event.target)) { + setIsOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + useEffect(() => { + setTempFrom(from); + setTempTo(to); + }, [from, to]); + + const formatDisplayDate = (date) => { + if (!date) return ""; + return date.toLocaleDateString(undefined, { + day: "2-digit", + month: "short", + year: "numeric" + }); + }; + + const getDaysInMonth = (year, month) => { + return new Date(year, month + 1, 0).getDate(); + }; + + const getFirstDayOfMonth = (year, month) => { + const day = new Date(year, month, 1).getDay(); + return day === 0 ? 6 : day - 1; + }; + + const calendarDays = useMemo(() => { + const year = currentMonth.getFullYear(); + const month = currentMonth.getMonth(); + const daysInMonth = getDaysInMonth(year, month); + const firstDay = getFirstDayOfMonth(year, month); + + const days = []; + + const prevMonth = month === 0 ? 11 : month - 1; + const prevYear = month === 0 ? year - 1 : year; + const daysInPrevMonth = getDaysInMonth(prevYear, prevMonth); + + for (let i = firstDay - 1; i >= 0; i--) { + days.push({ + day: daysInPrevMonth - i, + date: new Date(prevYear, prevMonth, daysInPrevMonth - i), + isCurrentMonth: false + }); + } + + for (let i = 1; i <= daysInMonth; i++) { + days.push({ + day: i, + date: new Date(year, month, i), + isCurrentMonth: true + }); + } + + const nextMonth = month === 11 ? 0 : month + 1; + const nextYear = month === 11 ? year + 1 : year; + const remainingDays = 42 - days.length; + + for (let i = 1; i <= remainingDays; i++) { + days.push({ + day: i, + date: new Date(nextYear, nextMonth, i), + isCurrentMonth: false + }); + } + + return days; + }, [currentMonth]); + + const handleDayClick = (date) => { + if (selecting === "from") { + setTempFrom(date); + setSelecting("to"); + if (tempTo && date > tempTo) { + setTempTo(null); + } + } else { + if (tempFrom && date < tempFrom) { + setTempTo(tempFrom); + setTempFrom(date); + } else { + setTempTo(date); + } + const finalFrom = tempFrom && date < tempFrom ? date : tempFrom; + const finalTo = tempFrom && date < tempFrom ? tempFrom : date; + onChange(finalFrom, finalTo); + setSelecting("from"); + setIsOpen(false); + } + }; + + const isInRange = (date) => { + if (!tempFrom) return false; + if (selecting === "to" && tempFrom && hoverDate) { + const endDate = hoverDate; + if (endDate < tempFrom) { + return date >= endDate && date <= tempFrom; + } + return date >= tempFrom && date <= endDate; + } + return tempFrom && tempTo && date >= tempFrom && date <= tempTo; + }; + + const isRangeStart = (date) => { + if (selecting === "to" && tempFrom && hoverDate && hoverDate < tempFrom) { + return date.toDateString() === hoverDate.toDateString(); + } + return tempFrom && date.toDateString() === tempFrom.toDateString(); + }; + + const isRangeEnd = (date) => { + if (selecting === "to" && tempFrom && hoverDate) { + if (hoverDate < tempFrom) { + return date.toDateString() === tempFrom.toDateString(); + } + return date.toDateString() === hoverDate.toDateString(); + } + return tempTo && date.toDateString() === tempTo.toDateString(); + }; + + const isToday = (date) => { + return date.toDateString() === todayDateString; + }; + + const isSelected = (date) => { + return isRangeStart(date) || isRangeEnd(date); + }; + + const isDisabled = (date) => { + if (minDate && date < minDate) return true; + if (effectiveMaxDate && date > effectiveMaxDate) return true; + return false; + }; + + const prevMonth = () => { + setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1)); + }; + + const nextMonth = () => { + setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1)); + }; + + const isCurrentMonthView = () => { + const now = new Date(); + return currentMonth.getMonth() === now.getMonth() && + currentMonth.getFullYear() === now.getFullYear(); + }; + + const weekDays = [ + t("calendar.mon"), + t("calendar.tue"), + t("calendar.wed"), + t("calendar.thu"), + t("calendar.fri"), + t("calendar.sat"), + t("calendar.sun") + ]; + + return ( +
+
setIsOpen(!isOpen)} + > + + + {from && to ? ( + <>{formatDisplayDate(from)} - {formatDisplayDate(to)} + ) : ( + t("calendar.select_range") + )} + +
+ + {isOpen && ( +
+
+ + + {currentMonth.toLocaleDateString(undefined, { month: "long", year: "numeric" })} + + +
+ +
+
+ {weekDays.map((day) => ( +
{day}
+ ))} +
+
+ {calendarDays.map((item, index) => ( + + ))} +
+
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/client/src/common/components/DateRangePicker/index.js b/client/src/common/components/DateRangePicker/index.js new file mode 100644 index 00000000..b3d47a22 --- /dev/null +++ b/client/src/common/components/DateRangePicker/index.js @@ -0,0 +1 @@ +export {DateRangePicker as default} from './DateRangePicker'; \ No newline at end of file diff --git a/client/src/common/components/DateRangePicker/styles.sass b/client/src/common/components/DateRangePicker/styles.sass new file mode 100644 index 00000000..802ca71f --- /dev/null +++ b/client/src/common/components/DateRangePicker/styles.sass @@ -0,0 +1,181 @@ +@use "@/common/styles/colors" as * + +.date-range-picker + position: relative + display: inline-block + +.date-range-trigger + display: flex + align-items: center + gap: 0.5rem + padding: 0.5rem 0.875rem + background-color: $dark-gray + border: 1px solid $light-gray + border-radius: 0.5rem + cursor: pointer + transition: all 0.2s ease-in-out + + &:hover + border-color: $accent-primary + +.calendar-icon + color: $subtext + font-size: 0.875rem + +.date-range-text + color: $white + font-size: 0.8rem + font-weight: 600 + white-space: nowrap + +.date-range-popover + position: absolute + top: calc(100% + 0.375rem) + left: 0 + z-index: 1000 + background-color: $dark-gray + border: 1px solid $light-gray + border-radius: 0.75rem + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25) + overflow: hidden + padding: 1rem + animation: popoverFadeIn 0.2s ease-out + +@keyframes popoverFadeIn + from + opacity: 0 + transform: translateY(-8px) + to + opacity: 1 + transform: translateY(0) + +.calendar-nav + display: flex + align-items: center + justify-content: space-between + margin-bottom: 0.75rem + +.nav-btn + display: flex + align-items: center + justify-content: center + width: 1.75rem + height: 1.75rem + background: transparent + border: 1px solid $light-gray + border-radius: 0.375rem + color: $subtext + cursor: pointer + transition: all 0.15s ease + font-size: 0.75rem + + &:hover:not(:disabled) + border-color: $accent-primary + color: $white + background-color: $darker-gray + + &:disabled + opacity: 0.3 + cursor: not-allowed + +.current-month + color: $white + font-size: 0.8rem + font-weight: 600 + +.calendar-grid + min-width: 240px + +.weekdays + display: grid + grid-template-columns: repeat(7, 1fr) + margin-bottom: 0.375rem + +.weekday + text-align: center + color: $subtext + font-size: 0.625rem + font-weight: 600 + text-transform: uppercase + padding: 0.375rem 0 + +.days + display: grid + grid-template-columns: repeat(7, 1fr) + +.day-btn + display: flex + align-items: center + justify-content: center + width: 100% + height: 30px + background: transparent + border: none + color: $white + font-size: 0.75rem + font-weight: 600 + cursor: pointer + transition: background-color 0.1s ease, color 0.1s ease + border-radius: 0 + + &:hover:not(.disabled):not(.selected):not(.in-range) + background-color: $darker-gray + border-radius: 0.25rem + + &.other-month + color: $subtext + opacity: 0.4 + + &.in-range + background-color: rgba(16, 185, 129, 0.2) + + &.range-start + background-color: $accent-primary + color: #fff + font-weight: 600 + border-radius: 0.25rem 0 0 0.25rem + + &.range-end + border-radius: 0.25rem + + &.range-end + background-color: $accent-primary + color: #fff + font-weight: 600 + border-radius: 0 0.25rem 0.25rem 0 + + &.today:not(.selected) + position: relative + font-weight: 600 + + &::after + content: "" + position: absolute + bottom: 3px + left: 50% + transform: translateX(-50%) + width: 3px + height: 3px + background-color: $accent-primary + border-radius: 50% + + &.disabled + opacity: 0.25 + cursor: not-allowed + +@media screen and (max-width: 600px) + .date-range-popover + position: fixed + top: 50% + left: 50% + transform: translate(-50%, -50%) + max-width: calc(100vw - 2rem) + max-height: calc(100vh - 2rem) + overflow-y: auto + + .calendar-grid + min-width: unset + + .day-btn + height: 28px + font-size: 0.7rem diff --git a/client/src/common/components/Dropdown/DropdownComponent.jsx b/client/src/common/components/Dropdown/DropdownComponent.jsx index ae8668db..a278ce14 100644 --- a/client/src/common/components/Dropdown/DropdownComponent.jsx +++ b/client/src/common/components/Dropdown/DropdownComponent.jsx @@ -1,31 +1,27 @@ import React, {useContext, useEffect, useRef, useState} from "react"; import "./styles.sass"; import { - faArrowDown, - faArrowUp, faCircleNodes, faClock, faGlobeEurope, faInfo, faKey, faPause, - faPingPongPaddleBall, faPlay, - faWandMagicSparkles, faCheck, faExclamationTriangle, faSliders, faHardDrive, faMoon, - faSun + faSun, + faGauge } from "@fortawesome/free-solid-svg-icons"; import {ConfigContext} from "@/common/contexts/Config"; import {StatusContext} from "@/common/contexts/Status"; import {InputDialogContext} from "@/common/contexts/InputDialog"; import {ThemeContext} from "@/common/contexts/Theme"; -import {baseRequest, jsonRequest, patchRequest, postRequest} from "@/common/utils/RequestUtil"; -import {creditsInfo, recommendationsInfo} from "@/common/components/Dropdown/utils/infos"; -import {levelOptions, selectOptions} from "@/common/components/Dropdown/utils/options"; +import {baseRequest, patchRequest, postRequest} from "@/common/utils/RequestUtil"; + import {levelOptions, selectOptions} from "@/common/components/Dropdown/utils/options"; import {parseCron, stringifyCron} from "@/common/components/Dropdown/utils/utils"; import {t} from "i18next"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; @@ -35,6 +31,7 @@ import {IntegrationDialog} from "@/common/components/IntegrationDialog"; import LanguageDialog from "@/common/components/LanguageDialog"; import ProviderDialog from "@/common/components/ProviderDialog"; import StorageDialog from "@/common/components/StorageDialog"; +import OptimalValuesDialog from "@/common/components/OptimalValuesDialog"; const DropdownComponent = ({isOpen, switchDropdown}) => { const [config, reloadConfig] = useContext(ConfigContext); @@ -49,6 +46,7 @@ const DropdownComponent = ({isOpen, switchDropdown}) => { const [showLanguageDialog, setShowLanguageDialog] = useState(false); const [showProviderDialog, setShowProviderDialog] = useState(false); const [showStorageDialog, setShowStorageDialog] = useState(false); + const [showOptimalValuesDialog, setShowOptimalValuesDialog] = useState(false); const ref = useRef(); useEffect(() => { @@ -86,36 +84,6 @@ const DropdownComponent = ({isOpen, switchDropdown}) => { }), 160); } - const updatePing = async () => patchDialog("ping", (value) => ({ - title: t("update.ping_title"), placeholder: t("update.ping_placeholder"), value - })); - - const updateUpload = async () => patchDialog("upload", (value) => ({ - title: t("update.upload_title"), placeholder: t("update.upload_placeholder"), value - })); - - const updateDownload = async () => patchDialog("download", (value) => ({ - title: t("update.download_title"), placeholder: t("update.download_placeholder"), value - })); - - const recommendedSettings = async () => { - const result = await jsonRequest("/recommendations"); - - if (!result.message) { - setDialog({ - title: t("update.recommendations_set"), - description: recommendationsInfo(result.ping, result.download, result.upload), - buttonText: t("dialog.apply"), - onSuccess: async () => { - await patchRequest("/config/ping", {value: result.ping}); - await patchRequest("/config/download", {value: result.download}); - await patchRequest("/config/upload", {value: result.upload}); - showFeedback(); - } - }); - } else setDialog({title: t("update.recommendations_title"), description: t("info.recommendations_error"), buttonText: t("dialog.okay")}); - } - const updatePassword = async () => { const passwordSet = currentNode !== 0 ? findNode(currentNode).password : localStorage.getItem("password") != null; @@ -177,8 +145,6 @@ const DropdownComponent = ({isOpen, switchDropdown}) => { } else postRequest("/speedtests/continue").then(updateStatus); } - const showCredits = () => setDialog({title: "MySpeed", description: creditsInfo(), buttonText: t("dialog.close")}); - const showProviderDetails = () => setDialog({title: t("dropdown.provider"), description: config.previewMessage, buttonText: t("dialog.close")}); const handleThemeToggle = () => { @@ -187,10 +153,7 @@ const DropdownComponent = ({isOpen, switchDropdown}) => { }; const options = [ - {run: updatePing, icon: faPingPongPaddleBall, text: t("dropdown.ping")}, - {run: updateUpload, icon: faArrowUp, text: t("dropdown.upload")}, - {run: updateDownload, icon: faArrowDown, text: t("dropdown.download")}, - {run: recommendedSettings, icon: faWandMagicSparkles, text: t("dropdown.recommendations")}, + {run: () => setShowOptimalValuesDialog(true), icon: faGauge, text: t("dropdown.optimal_values")}, {hr: true, key: 1}, {run: () => setShowProviderDialog(true), icon: faSliders, text: t("dropdown.change_provider")}, {run: () => setShowStorageDialog(true), icon: faHardDrive, text: t("dropdown.storage")}, @@ -201,7 +164,6 @@ const DropdownComponent = ({isOpen, switchDropdown}) => { {hr: true, key: 2}, {run: handleThemeToggle, icon: isDarkMode ? faSun : faMoon, text: t(isDarkMode ? "dropdown.light_mode" : "dropdown.dark_mode"), allowView: true}, {run: () => setShowLanguageDialog(true), icon: faGlobeEurope, text: t("dropdown.language"), allowView: true}, - {run: showCredits, icon: faInfo, text: t("dropdown.info"), allowView: true, previewHidden: true}, {run: showProviderDetails, icon: faInfo, text: t("dropdown.provider"), previewShown: true} ]; @@ -211,6 +173,7 @@ const DropdownComponent = ({isOpen, switchDropdown}) => { {showLanguageDialog && setShowLanguageDialog(false)}/>} {showProviderDialog && setShowProviderDialog(false)}/>} {showStorageDialog && setShowStorageDialog(false)}/>} + {showOptimalValuesDialog && setShowOptimalValuesDialog(false)}/>}

{t("dropdown.settings")}

diff --git a/client/src/common/components/Dropdown/styles.sass b/client/src/common/components/Dropdown/styles.sass index e954fbec..1d483634 100644 --- a/client/src/common/components/Dropdown/styles.sass +++ b/client/src/common/components/Dropdown/styles.sass @@ -15,12 +15,14 @@ width: auto overflow: auto border-radius: 10px - box-shadow: 0 8px 16px 0 $dark-gray + box-shadow: 0 8px 16px 0 rgba(0, 0, 0, 0.3) border: 1px solid $light-gray right: 0 - z-index: 1 + z-index: 200 padding: 15px background-color: $dark-gray + backdrop-filter: blur(4px) + -webkit-backdrop-filter: blur(4px) .dropdown-invisible visibility: hidden @@ -55,7 +57,7 @@ font-weight: 500 .dropdown-item:hover - color: $green + color: $accent-primary .dropdown-item svg width: 25px diff --git a/client/src/common/components/Dropdown/utils/infos.jsx b/client/src/common/components/Dropdown/utils/infos.jsx deleted file mode 100644 index 96d17079..00000000 --- a/client/src/common/components/Dropdown/utils/infos.jsx +++ /dev/null @@ -1,9 +0,0 @@ -import {DONATION_URL, PROJECT_URL, WEB_URL} from "@/index"; -import {Trans} from "react-i18next"; - - -export const creditsInfo = () => , - Github: , Donate: }}>info.credits - -export const recommendationsInfo = (ping, down, up) => }} - values={{ping, down, up}}>info.recommendations_info \ No newline at end of file diff --git a/client/src/common/components/DropdownSelect/DropdownSelect.jsx b/client/src/common/components/DropdownSelect/DropdownSelect.jsx new file mode 100644 index 00000000..94e86553 --- /dev/null +++ b/client/src/common/components/DropdownSelect/DropdownSelect.jsx @@ -0,0 +1,49 @@ +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faChevronDown, faPlus} from "@fortawesome/free-solid-svg-icons"; +import {useState, useRef} from "react"; +import "./styles.sass"; + +export const DropdownSelect = ({ + items, + onSelect, + buttonText, + buttonIcon = faPlus, + disabled = false +}) => { + const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef(null); + + const handleBlur = (event) => { + if (!containerRef.current?.contains(event.relatedTarget)) { + setIsOpen(false); + } + }; + + const handleSelect = (item) => { + onSelect(item); + setIsOpen(false); + }; + + if (disabled) return null; + + return ( +
+ + + {isOpen && ( +
+ {items.map((item, index) => ( +
handleSelect(item)} tabIndex={0}> + {item.icon && } + {item.label} +
+ ))} +
+ )} +
+ ); +}; diff --git a/client/src/common/components/DropdownSelect/index.js b/client/src/common/components/DropdownSelect/index.js new file mode 100644 index 00000000..4c81cc84 --- /dev/null +++ b/client/src/common/components/DropdownSelect/index.js @@ -0,0 +1 @@ +export {DropdownSelect as default} from "./DropdownSelect"; diff --git a/client/src/common/components/DropdownSelect/styles.sass b/client/src/common/components/DropdownSelect/styles.sass new file mode 100644 index 00000000..51a26638 --- /dev/null +++ b/client/src/common/components/DropdownSelect/styles.sass @@ -0,0 +1,68 @@ +@use "@/common/styles/colors" as * + +.dropdown-select-container + position: relative + display: flex + justify-content: flex-end + outline: none + +.dropdown-select-btn + display: flex + align-items: center + gap: 0.5rem + padding: 0.625rem 1rem + background-color: $darker-gray + border: 1px solid $light-gray + border-radius: 0.5rem + color: $subtext + font-size: 0.875rem + font-weight: 500 + cursor: pointer + transition: all 0.15s ease + + &:hover + border-color: $accent-primary + color: $accent-primary + + svg + font-size: 0.8rem + +.dropdown-select-chevron + transition: transform 0.2s ease + margin-left: 0.25rem + + &.rotated + transform: rotate(180deg) + +.dropdown-select-menu + position: absolute + top: auto + bottom: calc(100% + 0.5rem) + right: 0 + min-width: 12rem + background-color: $darker-gray + border: 1px solid $light-gray + border-radius: 0.5rem + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3) + z-index: 1000 + overflow: hidden + +.dropdown-select-item + display: flex + align-items: center + gap: 0.75rem + padding: 0.75rem 1rem + color: $subtext + cursor: pointer + transition: all 0.15s ease + + &:hover + background-color: $light-gray + color: $white + + svg + width: 1.1rem + font-size: 1rem + + span + font-size: 0.9rem diff --git a/client/src/common/components/ExpandableCard/ExpandableCard.jsx b/client/src/common/components/ExpandableCard/ExpandableCard.jsx new file mode 100644 index 00000000..5e1b91c3 --- /dev/null +++ b/client/src/common/components/ExpandableCard/ExpandableCard.jsx @@ -0,0 +1,53 @@ +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faChevronDown, faChevronUp} from "@fortawesome/free-solid-svg-icons"; +import {useState} from "react"; +import "./styles.sass"; + +export const ExpandableCard = ({ + icon, + title, + subtitle, + statusDot, + actions, + children, + defaultExpanded = false, + error = false, + success = false +}) => { + const [expanded, setExpanded] = useState(defaultExpanded); + + return ( +
+
setExpanded(!expanded)}> +
+ {icon && ( +
+ +
+ )} +
+

{title}

+ {(subtitle || statusDot) && ( +
+ {statusDot && } + {subtitle && {subtitle}} +
+ )} +
+
+
+ {actions} + +
+
+ + {expanded && ( +
+ {children} +
+ )} +
+ ); +}; diff --git a/client/src/common/components/ExpandableCard/index.js b/client/src/common/components/ExpandableCard/index.js new file mode 100644 index 00000000..5107fe6c --- /dev/null +++ b/client/src/common/components/ExpandableCard/index.js @@ -0,0 +1 @@ +export {ExpandableCard as default} from "./ExpandableCard"; \ No newline at end of file diff --git a/client/src/common/components/ExpandableCard/styles.sass b/client/src/common/components/ExpandableCard/styles.sass new file mode 100644 index 00000000..b0be103d --- /dev/null +++ b/client/src/common/components/ExpandableCard/styles.sass @@ -0,0 +1,131 @@ +@use "@/common/styles/colors" as * + +.expandable-card + background-color: $darker-gray + border: 1px solid $light-gray + border-radius: 0.75rem + transition: all 0.2s ease + + &.card-error + border-color: $accent-danger + + &.card-success + border-color: $accent-primary + +.expandable-card-header + display: flex + align-items: center + justify-content: space-between + padding: 0.875rem 1rem + cursor: pointer + user-select: none + + &:hover + background-color: rgba(255, 255, 255, 0.02) + border-radius: 0.75rem + +.expandable-card-info + display: flex + align-items: center + gap: 0.75rem + +.expandable-card-icon + display: flex + align-items: center + justify-content: center + width: 2.5rem + height: 2.5rem + background-color: $light-gray + border-radius: 0.5rem + color: $white + + svg + font-size: 1.1rem + +.expandable-card-details + h3 + margin: 0 + font-size: 0.95rem + font-weight: 600 + color: $white + max-width: 12rem + overflow: hidden + text-overflow: ellipsis + white-space: nowrap + +.expandable-card-status + display: flex + align-items: center + gap: 0.35rem + margin-top: 0.2rem + +.status-dot + width: 0.45rem + height: 0.45rem + border-radius: 50% + + &.status-active + background-color: $accent-primary + + &.status-inactive + background-color: $light-gray + + &.status-error + background-color: $accent-danger + +.status-text + font-size: 0.75rem + color: $subtext + +.expandable-card-actions + display: flex + align-items: center + gap: 0.25rem + +.card-action-btn + display: flex + align-items: center + justify-content: center + width: 2rem + height: 2rem + background: none + border: none + border-radius: 0.375rem + color: $subtext + cursor: pointer + transition: all 0.15s ease + + &:hover + background-color: $light-gray + + svg + font-size: 0.9rem + +.card-action-btn.save-btn:hover + color: $accent-primary + +.card-action-btn.delete-btn:hover + color: $accent-danger + +.card-action-btn.delete-btn.confirm + color: $accent-danger + background-color: rgba(239, 68, 68, 0.1) + +.card-action-btn.success-indicator + color: $accent-primary + cursor: default + + &:hover + background: none + +.expandable-card-body + padding: 1rem + display: flex + flex-direction: column + gap: 0.75rem + border-top: 1px solid $light-gray + margin-top: 0.5rem + +@media (max-width: 600px) + .expandable-card-details h3 + max-width: 8rem diff --git a/client/src/common/components/FormField/FormField.jsx b/client/src/common/components/FormField/FormField.jsx new file mode 100644 index 00000000..8af1c9e3 --- /dev/null +++ b/client/src/common/components/FormField/FormField.jsx @@ -0,0 +1,45 @@ +import ToggleSwitch from "@/common/components/ToggleSwitch"; +import "./styles.sass"; + +export const FormField = ({ + label, + type = "text", + value, + onChange, + placeholder, + error = false, + disabled = false +}) => ( +
+ + + {type === "text" && ( + onChange(e.target.value)} + placeholder={placeholder} + disabled={disabled} + /> + )} + + {type === "textarea" && ( +