Add ExportButton component for downloading speedtest data in CSV/JSON formats

This commit is contained in:
Mathias Wagner
2026-01-22 22:35:07 +01:00
parent 0d81f56b17
commit e7056e2cbe
6 changed files with 205 additions and 0 deletions

View File

@@ -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",

View File

@@ -279,6 +279,9 @@
},
"statistics": {
"average": "Average",
"export": {
"button": "Export"
},
"overview": {
"total_title": "Total tests",
"total_description": "The number of executed tests",

View File

@@ -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 (
<div className="export-button-container">
<button
className="export-button"
ref={buttonRef}
onClick={() => setIsOpen(!isOpen)}
disabled={exporting}
>
<FontAwesomeIcon icon={faDownload} className="export-icon" />
<span className="export-text">{t("statistics.export.button")}</span>
<FontAwesomeIcon icon={faChevronDown} className={`chevron-icon ${isOpen ? 'open' : ''}`} />
</button>
{isOpen && (
<div className="export-dropdown" ref={dropdownRef}>
<div className="export-option" onClick={() => handleExport('csv')}>
<FontAwesomeIcon icon={faFileLines} />
<span>{t("storage.csv")}</span>
</div>
<div className="export-option" onClick={() => handleExport('json')}>
<FontAwesomeIcon icon={faCode} />
<span>{t("storage.json")}</span>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1 @@
export {ExportButton as default} from "./ExportButton.jsx";

View File

@@ -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)

View File

@@ -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}
/>
<ExportButton dateRange={dateRange} />
</div>
<OverviewChart tests={statistics.tests} time={statistics.time} dateRange={dateRange} onClick={() => setExpandedChart('overview')}/>