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
+ v{version}
+
+
+
+
+
+ MySpeed is an open-source speedtest analysis software that records your internet speed for up to 30 days.
+
+
+
+
+
+
+ {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 (
+
+ );
+};
\ 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" && (
+
+);
diff --git a/client/src/common/components/FormField/index.js b/client/src/common/components/FormField/index.js
new file mode 100644
index 00000000..3f08f006
--- /dev/null
+++ b/client/src/common/components/FormField/index.js
@@ -0,0 +1 @@
+export {FormField as default} from "./FormField";
\ No newline at end of file
diff --git a/client/src/common/components/FormField/styles.sass b/client/src/common/components/FormField/styles.sass
new file mode 100644
index 00000000..4c99c6c1
--- /dev/null
+++ b/client/src/common/components/FormField/styles.sass
@@ -0,0 +1,55 @@
+@use "@/common/styles/colors" as *
+
+.form-field
+ display: flex
+ align-items: center
+ justify-content: space-between
+ gap: 1rem
+
+ label:first-child
+ font-size: 0.85rem
+ color: $subtext
+ flex: 1
+
+.form-field-error
+ color: $accent-danger !important
+
+.form-field-input
+ width: 55%
+ flex-shrink: 0
+ background-color: $dark-gray
+ border: 1px solid $light-gray
+ border-radius: 0.5rem
+ padding: 0.5rem 0.75rem
+ font-size: 0.85rem
+ color: $white
+ font-family: inherit
+ transition: border-color 0.15s ease
+
+ &:focus
+ outline: none
+ border-color: $accent-primary
+
+ &.input-error
+ border-color: $accent-danger
+
+ &::placeholder
+ color: $subtext
+ opacity: 0.6
+
+ &:disabled
+ opacity: 0.5
+ cursor: not-allowed
+
+.form-field-textarea
+ min-height: 4rem
+ resize: vertical
+
+@media (max-width: 600px)
+ .form-field
+ flex-direction: column
+ align-items: flex-start
+ gap: 0.5rem
+
+ .form-field-input
+ width: 100%
diff --git a/client/src/common/components/Header/HeaderComponent.jsx b/client/src/common/components/Header/HeaderComponent.jsx
index 64b792c2..8feb84f0 100644
--- a/client/src/common/components/Header/HeaderComponent.jsx
+++ b/client/src/common/components/Header/HeaderComponent.jsx
@@ -5,8 +5,8 @@ import {
faGaugeHigh,
faGear,
faLock,
- faServer,
- faClose
+ faClose,
+ faServer
} from "@fortawesome/free-solid-svg-icons";
import { useContext, useEffect, useState } from "react";
import DropdownComponent from "../Dropdown/DropdownComponent";
@@ -17,12 +17,12 @@ import { jsonRequest, postRequest } from "@/common/utils/RequestUtil";
import { updateInfo } from "@/common/components/Header/utils/infos";
import { t } from "i18next";
import { ConfigContext } from "@/common/contexts/Config";
-import { LoadingDialog } from "@/common/components/LoadingDialog";
import { NodeContext } from "@/common/contexts/Node";
import { WEB_URL } from "@/index";
import { Trans } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import Pagination from "./components/Pagination";
+import AboutDialog from "@/common/components/AboutDialog";
const HeaderComponent = () => {
const findNode = useContext(NodeContext)[4];
@@ -32,12 +32,12 @@ const HeaderComponent = () => {
const [setDialog] = useContext(InputDialogContext);
const [icon, setIcon] = useState(faGear);
- const [status, updateStatus] = useContext(StatusContext);
- const [startedManually, setStartedManually] = useState(false);
+ const [status, updateStatus, setRunning] = useContext(StatusContext);
const {updateTests} = useContext(SpeedtestContext);
const [config, reloadConfig, checkConfig] = useContext(ConfigContext);
const [updateAvailable, setUpdateAvailable] = useState("");
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const [showAboutDialog, setShowAboutDialog] = useState(false);
const switchDropdown = () => {
setIsDropdownOpen(!isDropdownOpen);
@@ -74,15 +74,10 @@ const HeaderComponent = () => {
buttonText: t("dialog.okay")
});
- if (status.running) return setDialog({
- title: t("failed"),
- description: t("header.running"),
- buttonText: t("dialog.okay")
- });
+ if (status.running) return;
- setStartedManually(true);
-
- postRequest("/speedtests/run").then(updateTests).then(updateStatus).then(() => setStartedManually(false));
+ setRunning(true);
+ postRequest("/speedtests/run").then(updateTests).then(updateStatus);
}
const openDownloadPage = () => window.open(WEB_URL + "/install", "_blank");
@@ -106,11 +101,11 @@ const HeaderComponent = () => {
return (