diff --git a/client/public/assets/locales/de.json b/client/public/assets/locales/de.json index c9a0c4f3..f4fbfec1 100644 --- a/client/public/assets/locales/de.json +++ b/client/public/assets/locales/de.json @@ -245,6 +245,9 @@ "invalid_view": "Ungültige Ansicht" }, "statistics": { + "export": { + "button": "Exportieren" + }, "overview": { "total_title": "Insgesamte Tests", "total_description": "Die Anzahl der ausgeführten Tests", diff --git a/client/public/assets/locales/en.json b/client/public/assets/locales/en.json index c430af0d..4cc601ee 100644 --- a/client/public/assets/locales/en.json +++ b/client/public/assets/locales/en.json @@ -279,6 +279,9 @@ }, "statistics": { "average": "Average", + "export": { + "button": "Export" + }, "overview": { "total_title": "Total tests", "total_description": "The number of executed tests", diff --git a/client/src/common/components/ExportButton/ExportButton.jsx b/client/src/common/components/ExportButton/ExportButton.jsx new file mode 100644 index 00000000..8ad9fd2f --- /dev/null +++ b/client/src/common/components/ExportButton/ExportButton.jsx @@ -0,0 +1,87 @@ +import { useState, useRef, useEffect } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faDownload, faChevronDown, faFileLines, faCode } from "@fortawesome/free-solid-svg-icons"; +import { t } from "i18next"; +import "./styles.sass"; + +export const ExportButton = ({ dateRange }) => { + const [isOpen, setIsOpen] = useState(false); + const [exporting, setExporting] = useState(false); + const dropdownRef = useRef(null); + const buttonRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target) && + buttonRef.current && !buttonRef.current.contains(event.target)) { + setIsOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const formatDateParam = (date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + }; + + const handleExport = async (format) => { + setExporting(true); + setIsOpen(false); + + try { + const fromParam = formatDateParam(dateRange.from); + const toParam = formatDateParam(dateRange.to); + const url = `/api/speedtests/export?from=${fromParam}&to=${toParam}&format=${format}`; + + const response = await fetch(url); + if (!response.ok) throw new Error('Export failed'); + + const blob = await response.blob(); + const downloadUrl = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = downloadUrl; + a.download = `myspeed-export-${fromParam}-to-${toParam}.${format}`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(downloadUrl); + document.body.removeChild(a); + } catch (error) { + console.error('Export failed:', error); + } finally { + setExporting(false); + } + }; + + return ( +
+ + + {isOpen && ( +
+
handleExport('csv')}> + + {t("storage.csv")} +
+
handleExport('json')}> + + {t("storage.json")} +
+
+ )} +
+ ); +}; diff --git a/client/src/common/components/ExportButton/index.js b/client/src/common/components/ExportButton/index.js new file mode 100644 index 00000000..df7fb806 --- /dev/null +++ b/client/src/common/components/ExportButton/index.js @@ -0,0 +1 @@ +export {ExportButton as default} from "./ExportButton.jsx"; \ No newline at end of file diff --git a/client/src/common/components/ExportButton/styles.sass b/client/src/common/components/ExportButton/styles.sass new file mode 100644 index 00000000..899ec08d --- /dev/null +++ b/client/src/common/components/ExportButton/styles.sass @@ -0,0 +1,109 @@ +@use "@/common/styles/colors" as * + +.export-button-container + position: relative + display: inline-block + +.export-button + 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 + color: $white + font-family: inherit + font-size: 0.8rem + font-weight: 600 + + &:hover:not(:disabled) + border-color: $accent-primary + + &:disabled + opacity: 0.6 + cursor: not-allowed + +.export-icon + color: $subtext + font-size: 0.875rem + +.export-text + white-space: nowrap + +.chevron-icon + color: $subtext + font-size: 0.625rem + transition: transform 0.2s ease + + &.open + transform: rotate(180deg) + +.export-dropdown + position: absolute + top: calc(100% + 0.375rem) + right: 0 + left: 0 + z-index: 1000 + background-color: $dark-gray + border: 1px solid $light-gray + border-radius: 0.6rem + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3) + overflow: hidden + padding: 6px + animation: dropdownFadeIn 0.15s ease-out + +@keyframes dropdownFadeIn + from + opacity: 0 + transform: translateY(-8px) scale(0.98) + to + opacity: 1 + transform: translateY(0) scale(1) + +.export-option + display: flex + align-items: center + gap: 10px + padding: 8px 12px + border-radius: 6px + color: $subtext + cursor: pointer + transition: all 0.15s ease + + &:hover + background-color: $darker-gray + color: $white + + svg + color: $white + + svg + width: 14px + height: 14px + flex-shrink: 0 + + span + font-size: 0.875rem + font-weight: 500 + +@media screen and (max-width: 600px) + .export-text + display: none + + .export-button + padding: 0.5rem 0.625rem + + .chevron-icon + display: none + + .export-dropdown + position: fixed + top: auto + bottom: 1rem + left: 50% + right: auto + transform: translateX(-50%) + max-width: calc(100vw - 2rem) diff --git a/client/src/pages/Statistics/Statistics.jsx b/client/src/pages/Statistics/Statistics.jsx index 2f8065de..d374811b 100644 --- a/client/src/pages/Statistics/Statistics.jsx +++ b/client/src/pages/Statistics/Statistics.jsx @@ -22,6 +22,7 @@ import OverviewChart from "@/pages/Statistics/charts/OverviewChart"; import AverageChart from "@/pages/Statistics/charts/AverageChart"; import HourlyChart from "@/pages/Statistics/charts/HourlyChart.jsx"; import ConsistencyChart from "@/pages/Statistics/charts/ConsistencyChart"; +import ExportButton from "@/common/components/ExportButton"; import i18n, {t} from "i18next"; import "./styles.sass"; @@ -161,6 +162,7 @@ export const Statistics = () => { to={dateRange.to} onChange={handleDateRangeChange} /> + setExpandedChart('overview')}/>