diff --git a/frontend/fonts/Inter-latin-ext.woff2 b/frontend/fonts/Inter-latin-ext.woff2 new file mode 100644 index 000000000..887153b81 Binary files /dev/null and b/frontend/fonts/Inter-latin-ext.woff2 differ diff --git a/frontend/fonts/Inter-latin.woff2 b/frontend/fonts/Inter-latin.woff2 new file mode 100644 index 000000000..798d6d9f6 Binary files /dev/null and b/frontend/fonts/Inter-latin.woff2 differ diff --git a/frontend/images/background-original.jpeg b/frontend/images/background-original.jpeg new file mode 100644 index 000000000..8eef32ac8 Binary files /dev/null and b/frontend/images/background-original.jpeg differ diff --git a/frontend/images/background.jpeg b/frontend/images/background.jpeg new file mode 100644 index 000000000..e0f891641 Binary files /dev/null and b/frontend/images/background.jpeg differ diff --git a/frontend/images/chevron.svg b/frontend/images/chevron.svg new file mode 100644 index 000000000..d520abd12 --- /dev/null +++ b/frontend/images/chevron.svg @@ -0,0 +1,22 @@ + + + + + + + + + + \ No newline at end of file diff --git a/frontend/images/close-button.svg b/frontend/images/close-button.svg new file mode 100644 index 000000000..befe4e6be --- /dev/null +++ b/frontend/images/close-button.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/images/favicon.svg b/frontend/images/favicon.svg new file mode 100644 index 000000000..a38e24d64 --- /dev/null +++ b/frontend/images/favicon.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/images/logo.svg b/frontend/images/logo.svg new file mode 100644 index 000000000..709aa1cf8 --- /dev/null +++ b/frontend/images/logo.svg @@ -0,0 +1,66 @@ + \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100755 index 000000000..cf7b7b04a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,151 @@ + + + + + + + + + + + LibreSpeed - Free and Open Source Speedtest + + + +
+ LibreSpeed +
+
+

Free and Open Source Speedtest.

+

No Flash, No Java, No Websockets, No Bullsh*t

+ +
+
+
+ select... +
+

current server

+

searching nearest server...

+
+ + +
+ + + + +
+ + +
+
+
+

00 Mbps

+

Download

+
+ +
+
+
+

00 Mbps

+

Upload

+
+ + +
+ + +
+ + + +
+ Close +
+ Test results in graphical form + +
+ + +
+ Close +
+
+

Privacy Policy

+

+ This HTML5 speed test server is configured with telemetry enabled. +

+ +

What data we collect

+

+ At the end of the test, the following data is collected and stored: +

+ + + +

How we use the data

+

Data collected through this service is used to:

+ + + +

No personal information is disclosed to third parties.

+ +

Your consent

+

+ By starting the test, you consent to the terms of this privacy policy. +

+ +

Data removal

+

+ If you want to have your information deleted, you need to provide + either the ID of the test or your IP address. This is the only way to + identify your data, without this information we won't be able to + comply with your request. +

+

+ Contact this email address for all deletion requests: + TO BE FILLED BY DEVELOPER. +

+
+ +
+ + diff --git a/frontend/javascript/index.js b/frontend/javascript/index.js new file mode 100644 index 000000000..200a45c36 --- /dev/null +++ b/frontend/javascript/index.js @@ -0,0 +1,397 @@ +/** + * Design by fromScratch Studio - 2022, 2023 (fromscratch.io) + * Implementation in HTML/CSS/JS by Timendus - 2024 (https://github.com/Timendus) + * + * See https://github.com/librespeed/speedtest/issues/585 + */ + +// States the UI can be in +const INITIALIZING = 0; +const READY = 1; +const RUNNING = 2; +const FINISHED = 3; + +// Keep some global state here +const testState = { + state: INITIALIZING, + speedtest: null, + servers: [], + selectedServerDirty: false, + testData: null, + testDataDirty: false, + telemetryEnabled: false, +}; + +// Bootstrap the application when the DOM is ready +window.addEventListener("DOMContentLoaded", async () => { + createSpeedtest(); + hookUpButtons(); + startRenderingLoop(); + applySettingsJSON(); + applyServerListJSON(); +}); + +/** + * Create a new Speedtest and hook it into the global state + */ +function createSpeedtest() { + testState.speedtest = new Speedtest(); + testState.speedtest.onupdate = (data) => { + testState.testData = data; + testState.testDataDirty = true; + }; + testState.speedtest.onend = (aborted) => + (testState.state = aborted ? READY : FINISHED); +} + +/** + * Make all the buttons respond to the right clicks + */ +function hookUpButtons() { + document + .querySelector("#start-button") + .addEventListener("click", startButtonClickHandler); + document + .querySelector("#choose-privacy") + .addEventListener("click", () => + document.querySelector("#privacy").showModal() + ); + document + .querySelector("#share-results") + .addEventListener("click", () => + document.querySelector("#share").showModal() + ); + document + .querySelector("#copy-link") + .addEventListener("click", copyLinkButtonClickHandler); + document + .querySelectorAll(".close-dialog, #close-privacy") + .forEach((element) => { + element.addEventListener("click", () => + document.querySelectorAll("dialog").forEach((modal) => modal.close()) + ); + }); +} + +/** + * Event listener for clicks on the main start button + */ +function startButtonClickHandler() { + switch (testState.state) { + case READY: + case FINISHED: + testState.speedtest.start(); + testState.state = RUNNING; + return; + case RUNNING: + testState.speedtest.abort(); + // testState.state is updated by `onend` handler of speedtest + return; + default: + return; + } +} + +/** + * Event listener for clicks on the "Copy link" button in the modal + */ +async function copyLinkButtonClickHandler() { + const link = document.querySelector("#share img").src; + await navigator.clipboard.writeText(link); + const button = document.querySelector("#copy-link"); + button.classList.add("active"); + button.textContent = "Copied!"; + setTimeout(() => { + button.classList.remove("active"); + button.textContent = "Copy link"; + }, 3000); +} + +/** + * Load settings from settings.json on the server and apply them + */ +async function applySettingsJSON() { + try { + const response = await fetch("settings.json"); + const settings = await response.json(); + if (!settings || typeof settings !== "object") { + return console.error("Settings are empty or malformed"); + } + for (let setting in settings) { + testState.speedtest.setParameter(setting, settings[setting]); + if ( + setting == "telemetry_level" && + settings[setting] && + settings[setting] != "off" && + settings[setting] != "disabled" && + settings[setting] != "false" + ) { + testState.telemetryEnabled = true; + document.querySelector("#privacy-warning").classList.remove("hidden"); + } + } + } catch (error) { + console.error("Failed to fetch settings:", error); + } +} + +/** + * Load server list from server-list.json on the server and populate the + * dropdown + */ +async function applyServerListJSON() { + try { + const response = await fetch("server-list.json"); + const servers = await response.json(); + if (!servers || !Array.isArray(servers) || servers.length === 0) { + return console.error("Server list is empty or malformed"); + } + testState.servers = servers; + populateDropdown(testState.servers); + if (servers.length > 1) { + testState.speedtest.addTestPoints(servers); + testState.speedtest.selectServer((server) => { + if (server) { + selectServer(server); + } else { + alert( + "Can't reach any of the speedtest servers! But you're on this page. Something weird is going on with your network." + ); + } + }); + } + } catch (error) { + console.error("Failed to fetch server list:", error); + } +} + +/** + * Add all the servers to the server selection dropdown and make it actually + * work. + * @param {Array} servers - an array of server objects + */ +function populateDropdown(servers) { + const serverSelector = document.querySelector("div.server-selector"); + const serverList = serverSelector.querySelector("ul.servers"); + serverSelector.classList.add("active"); + + // If we have only a single server, just show it + if (servers.length === 1) { + serverSelector.classList.add("single-server"); + selectServer(servers[0]); + return; + } + + // Make the dropdown open and close + serverSelector.addEventListener("click", () => { + serverList.classList.toggle("active"); + }); + document.addEventListener("click", (e) => { + if (e.target.closest("div.server-selector") !== serverSelector) + serverList.classList.remove("active"); + }); + + // Populate the list to choose from + servers.forEach((server) => { + const item = document.createElement("li"); + const link = document.createElement("a"); + link.href = "#"; + link.innerHTML = `${server.name}${ + server.sponsorName ? ` (${server.sponsorName})` : "" + }`; + link.addEventListener("click", () => selectServer(server)); + item.appendChild(link); + serverList.appendChild(item); + }); +} + +/** + * Set the given server as the selected server for the speedtest + * @param {Object} server - a server object + */ +function selectServer(server) { + testState.speedtest.setSelectedServer(server); + testState.selectedServerDirty = true; + testState.state = READY; +} + +/** + * Start the requestAnimationFrame UI rendering loop + */ +function startRenderingLoop() { + // Do these queries once to speed up the rendering itself + const serverSelector = document.querySelector("div.server-selector"); + const selectedServer = serverSelector.querySelector("#selected-server"); + const sponsor = serverSelector.querySelector("#sponsor"); + const startButton = document.querySelector("#start-button"); + const privacyWarning = document.querySelector("#privacy-warning"); + + const gauges = document.querySelectorAll("#download-gauge, #upload-gauge"); + const downloadProgress = document.querySelector("#download-gauge .progress"); + const uploadProgress = document.querySelector("#upload-gauge .progress"); + const downloadGauge = document.querySelector("#download-gauge .speed"); + const uploadGauge = document.querySelector("#upload-gauge .speed"); + const downloadText = document.querySelector("#download-gauge span"); + const uploadText = document.querySelector("#upload-gauge span"); + + const pingAndJitter = document.querySelectorAll(".ping, .jitter"); + const ping = document.querySelector("#ping"); + const jitter = document.querySelector("#jitter"); + const shareResults = document.querySelector("#share-results"); + const copyLink = document.querySelector("#copy-link"); + const resultsImage = document.querySelector("#results"); + + const buttonTexts = { + [INITIALIZING]: "Loading...", + [READY]: "Let's start baby", + [RUNNING]: "Abort", + [FINISHED]: "Restart", + }; + + // Show copy link button only if navigator.clipboard is available + copyLink.classList.toggle("hidden", !navigator.clipboard); + + function renderUI() { + // Make the main button reflect the current state + startButton.textContent = buttonTexts[testState.state]; + startButton.classList.toggle("disabled", testState.state === INITIALIZING); + startButton.classList.toggle("active", testState.state === RUNNING); + + // Disable the server selector while test is running + serverSelector.classList.toggle("disabled", testState.state === RUNNING); + + // Show selected server + if (testState.selectedServerDirty) { + const server = testState.speedtest.getSelectedServer(); + selectedServer.textContent = server.name; + if (server.sponsorName) { + if (server.sponsorURL) { + sponsor.innerHTML = `Sponsor: ${server.sponsorName}`; + } else { + sponsor.textContent = `Sponsor: ${server.sponsorName}`; + } + } else { + sponsor.innerHTML = " "; + } + testState.selectedServerDirty = false; + } + + // Activate the gauges when test running or finished + gauges.forEach((e) => + e.classList.toggle( + "enabled", + testState.state === RUNNING || testState.state === FINISHED + ) + ); + + // Show ping and jitter if data is available + pingAndJitter.forEach((e) => + e.classList.toggle( + "hidden", + !( + testState.testData && + testState.testData.pingStatus && + testState.testData.jitterStatus + ) + ) + ); + + // Show share button after test if server supports it + shareResults.classList.toggle( + "hidden", + !( + testState.state === FINISHED && + testState.telemetryEnabled && + testState.testData.testId + ) + ); + + if (testState.testDataDirty) { + // Set gauge rotations + downloadProgress.style = `--progress-rotation: ${ + testState.testData.dlProgress * 180 + }deg`; + uploadProgress.style = `--progress-rotation: ${ + testState.testData.ulProgress * 180 + }deg`; + downloadGauge.style = `--speed-rotation: ${mbpsToRotation( + testState.testData.dlStatus, + testState.testData.testState === 1 + )}deg`; + uploadGauge.style = `--speed-rotation: ${mbpsToRotation( + testState.testData.ulStatus, + testState.testData.testState === 3 + )}deg`; + + // Set numeric values + downloadText.textContent = numberToText(testState.testData.dlStatus); + uploadText.textContent = numberToText(testState.testData.ulStatus); + ping.textContent = numberToText(testState.testData.pingStatus); + jitter.textContent = numberToText(testState.testData.jitterStatus); + + // Set user's IP and provider + if (testState.testData.clientIp) { + privacyWarning.innerHTML = `You are connected through:
${testState.testData.clientIp}`; + privacyWarning.classList.remove("hidden"); + } + + // Set image for sharing results + if (testState.testData.testId) { + resultsImage.src = + window.location.href.substring( + 0, + window.location.href.lastIndexOf("/") + ) + + "/results/?id=" + + testState.testData.testId; + } + + testState.testDataDirty = false; + } + + requestAnimationFrame(renderUI); + } + + renderUI(); +} + +/** + * Convert a speed in Mbits per second to a rotation for the gauge + * @param {string} speed Speed in Mbits + * @param {boolean} oscillate If the gauge should wiggle a bit + * @returns {number} Rotation for the gauge in degrees + */ +function mbpsToRotation(speed, oscillate) { + speed = Number(speed); + if (speed <= 0) return 0; + + const minSpeed = 0; + const maxSpeed = 10000; // 10 Gbps maxes out the gauge + const minRotation = 0; + const maxRotation = 180; + + // Can't do log10 of values less than one, +1 all to keep it fair + const logMinSpeed = Math.log10(minSpeed + 1); + const logMaxSpeed = Math.log10(maxSpeed + 1); + const logSpeed = Math.log10(speed + 1); + + const power = (logSpeed - logMinSpeed) / (logMaxSpeed - logMinSpeed); + const oscillation = oscillate ? 1 + 0.01 * Math.sin(Date.now() / 100) : 1; + const rotation = power * oscillation * maxRotation; + + // Make sure we stay within bounds at all times + return Math.max(Math.min(rotation, maxRotation), minRotation); +} + +/** + * Convert a number to a user friendly version + * @param {string} value Speed, ping or jitter + * @returns {string} A text version with proper decimals + */ +function numberToText(value) { + if (!value) return "00"; + value = Number(value); + if (value < 10) return value.toFixed(2); + if (value < 100) return value.toFixed(1); + return value.toFixed(0); +} diff --git a/frontend/server-list.json b/frontend/server-list.json new file mode 100644 index 000000000..01a8e8c21 --- /dev/null +++ b/frontend/server-list.json @@ -0,0 +1,409 @@ +[ + { + "name": "Amsterdam, Netherlands", + "server": "//ams.speedtest.clouvider.net/backend", + "id": 51, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Clouvider", + "sponsorURL": "https://www.clouvider.co.uk/" + }, + { + "name": "Amsterdam, Netherlands (Rust backend)", + "server": "https://librespeed-rs.ir/", + "id": 95, + "dlURL": "backend/garbage", + "ulURL": "backend/empty", + "pingURL": "backend/empty", + "getIpURL": "backend/getIP", + "sponsorName": "Sudo Dios", + "sponsorURL": "https://github.com/SudoDios" + }, + { + "name": "Amsterdam, Netherlands", + "server": "https://amsspeed.sharktech.net", + "id": 94, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Sharktech", + "sponsorURL": "https://sharktech.net" + }, + { + "name": "Atlanta, United States", + "server": "//atl.speedtest.clouvider.net/backend", + "id": 53, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Clouvider", + "sponsorURL": "https://www.clouvider.co.uk/" + }, + { + "name": "Bangalore, India", + "server": "//in1.backend.librespeed.org/", + "id": 75, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "DigitalOcean", + "sponsorURL": "https://www.digitalocean.com" + }, + { + "name": "Bari, Italy", + "server": "https://st-be-ba1.infra.garr.it", + "id": 33, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Consortium GARR", + "sponsorURL": "//garr.it" + }, + { + "name": "Bologna, Italy", + "server": "https://st-be-bo1.infra.garr.it", + "id": 34, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Consortium GARR", + "sponsorURL": "//garr.it" + }, + { + "name": "Chicago, USA", + "server": "https://chispeed.sharktech.net", + "id": 93, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Sharktech", + "sponsorURL": "https://sharktech.net" + }, + { + "name": "Denver, USA", + "server": "https://denspeed.sharktech.net", + "id": 92, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Sharktech", + "sponsorURL": "https://sharktech.net" + }, + { + "name": "Frankfurt, Germany", + "server": "//fra.speedtest.clouvider.net/backend", + "id": 50, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Clouvider", + "sponsorURL": "https://www.clouvider.co.uk/" + }, + { + "name": "Frankfurt, Germany (FRA01)", + "server": "https://speedtest.lumischvps.cloud/", + "id": 86, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "LumischVPS", + "sponsorURL": "https://discord.gg/GxYzPwJmA2" + }, + { + "name": "Ghom, Iran (Amin IDC)", + "server": "https://fastme.ir/", + "id": 77, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Bardia Moshiri", + "sponsorURL": "https://bardia.tech/" + }, + { + "name": "Helsinki, Finland (3) (Hetzner)", + "server": "//finew.openspeed.org/", + "id": 22, + "dlURL": "backend437/garbage.php", + "ulURL": "backend437/empty.php", + "pingURL": "backend437/empty.php", + "getIpURL": "backend437/getIP.php", + "sponsorName": "Daily Health Insurance Group", + "sponsorURL": "//dhig.net/" + }, + { + "name": "Helsinki, Finland (5) (Hetzner)", + "server": "//fast.kabi.tk/", + "id": 24, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "KABI.tk", + "sponsorURL": "//kabi.tk" + }, + { + "name": "Johannesburg, South Africa", + "server": "//za1.backend.librespeed.org/", + "id": 70, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "HOSTAFRICA", + "sponsorURL": "https://www.hostafrica.co.za" + }, + { + "name": "Las Vegas, USA", + "server": "https://lasspeed.sharktech.net", + "id": 90, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Sharktech", + "sponsorURL": "https://sharktech.net" + }, + { + "name": "London, England", + "server": "//lon.speedtest.clouvider.net/backend", + "id": 49, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Clouvider", + "sponsorURL": "https://www.clouvider.co.uk/" + }, + { + "name": "Los Angeles, United States (1)", + "server": "//la.speedtest.clouvider.net/backend", + "id": 54, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Clouvider", + "sponsorURL": "https://www.clouvider.co.uk/" + }, + { + "name": "Los Angeles, USA", + "server": "https://laxspeed.sharktech.net", + "id": 91, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Sharktech", + "sponsorURL": "https://sharktech.net" + }, + { + "name": "New York, United States (2)", + "server": "//nyc.speedtest.clouvider.net/backend", + "id": 52, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Clouvider", + "sponsorURL": "https://www.clouvider.co.uk/" + }, + { + "name": "Nottingham, England (LayerIP)", + "server": "https://uk1.backend.librespeed.org", + "id": 43, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "fosshost.org", + "sponsorURL": "https://fosshost.org" + }, + { + "name": "Nuremberg, Germany (1) (Hetzner)", + "server": "//de1.backend.librespeed.org", + "id": 28, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Snopyta", + "sponsorURL": "https://snopyta.org" + }, + { + "name": "Nuremberg, Germany (2) (Hetzner)", + "server": "//de4.backend.librespeed.org", + "id": 27, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "LibreSpeed", + "sponsorURL": "https://librespeed.org" + }, + { + "name": "Nuremberg, Germany (3) (Hetzner)", + "server": "//de3.backend.librespeed.org", + "id": 30, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "LibreSpeed", + "sponsorURL": "https://librespeed.org" + }, + { + "name": "Nuremberg, Germany (4) (Hetzner)", + "server": "//de5.backend.librespeed.org", + "id": 31, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "LibreSpeed", + "sponsorURL": "https://librespeed.org" + }, + { + "name": "Nuremberg, Germany (6) (Hetzner)", + "server": "//librespeed.lukas-heinrich.com/", + "id": 46, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "luki9100", + "sponsorURL": "https://lukas-heinrich.com/" + }, + { + "name": "Poznan, Poland (INEA)", + "server": "https://speedtest.kamilszczepanski.com", + "id": 74, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Kamil Szczepa\u0144ski", + "sponsorURL": "https://kamilszczepanski.com" + }, + { + "name": "Prague, Czech Republic", + "server": "//speedtest.cesnet.cz", + "id": 79, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "CESNET", + "sponsorURL": "https://www.cesnet.cz" + }, + { + "name": "Prague, Czech Republic", + "server": "//librespeed.turris.cz", + "id": 85, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Turris", + "sponsorURL": "https://www.turris.com" + }, + { + "name": "Roma, Italy", + "server": "https://st-be-rm2.infra.garr.it", + "id": 35, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Consortium GARR", + "sponsorURL": "//garr.it" + }, + { + "name": "Serbia", + "server": "https://speedtest2.sox.rs", + "id": 87, + "dlURL": "libre/backend/garbage.php", + "ulURL": "libre/backend/empty.php", + "pingURL": "libre/backend/empty.php", + "getIpURL": "libre/backend/getIP.php", + "sponsorName": "Serbian Open eXchange", + "sponsorURL": "https://sox.rs" + }, + { + "name": "Singapore", + "server": "https://speedtest.dsgroupmedia.com", + "id": 68, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Salvatore Cahyo", + "sponsorURL": "https://salvatorecahyo.my.id" + }, + { + "name": "Tehran, Iran (Fanava)", + "server": "https://speedme.ir/", + "id": 76, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Bardia Moshiri", + "sponsorURL": "https://bardia.tech" + }, + { + "name": "Tehran, Iran (Faraso)", + "server": "https://st.bardia.tech", + "id": 80, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Bardia Moshiri", + "sponsorURL": "https://bardia.tech/" + }, + { + "name": "Tokyo, Japan", + "server": "https://librespeed.a573.net/", + "id": 82, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "A573", + "sponsorURL": "https://mirror.a573.net/" + }, + { + "name": "Vilnius, Lithuania (RackRay)", + "server": "//lt1.backend.librespeed.org/", + "id": 69, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Time4VPS", + "sponsorURL": "https://www.time4vps.com" + }, + { + "name": "Virginia, United States, OVH", + "server": "https://speed.riverside.rocks/", + "id": 78, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Riverside Rocks", + "sponsorURL": "https://riverside.rocks" + } +] diff --git a/frontend/settings.json b/frontend/settings.json new file mode 100644 index 000000000..8d2967c79 --- /dev/null +++ b/frontend/settings.json @@ -0,0 +1,9 @@ +{ + "telemetry_level": "off", + "test_order": "ID_U_P", + "time_dl_max": 12, + "time_ul_max": 12, + "time_dlGraceTime": 0, + "time_ulGraceTime": 0, + "time_auto": false +} diff --git a/frontend/styling/button.css b/frontend/styling/button.css new file mode 100644 index 000000000..7a25829f6 --- /dev/null +++ b/frontend/styling/button.css @@ -0,0 +1,83 @@ +/* The main "start the test" button and the share button */ + +button { + height: 6.8rem; + min-width: 26.4rem; + padding: 0 5rem; + margin: 2.5rem; + border-radius: 3.4rem; + border: 0; + + font-family: "Inter", sans-serif; + font-size: 2rem; + font-weight: 700; + letter-spacing: -0.1rem; + color: var(--button-text-color); + text-transform: uppercase; + cursor: pointer; + box-shadow: 0 0.4rem 1.6rem 0 var(--button-shadow-color); + + will-change: transform; + backface-visibility: hidden; + transform: scale(1) translate3d(0, 0, 0) perspective(1px); + + background: var(--button-gradient-1-color-1); + transition: background-position 0.2s, transform 0.2s; + background-position: 0% 0%; + background: linear-gradient( + 92.97deg, + var(--button-gradient-1-color-1) 0%, + var(--button-gradient-1-color-1) 33%, + var(--button-gradient-1-color-2) 40%, + var(--button-gradient-1-color-3) 66.71%, + var(--button-gradient-1-color-3) 100% + ); + background-size: 300% 100%; + + &.disabled { + cursor: default; + transform: scale(1) translate3d(0, 0, 0) perspective(1px); + background: var(--button-disabled-background-color); + } + &.small { + height: 4.7rem; + min-width: 20.2rem; + text-transform: lowercase; + } + &.inverted { + border: 1px solid var(--button-gradient-1-color-1); + color: transparent; + background-clip: text; + } + &.hidden { + opacity: 0; + pointer-events: none; + } + &:hover { + background-position: 60% 0%; + transform: scale(1.03) translate3d(0, 0, 0) perspective(1px); + } + &.active, + &:active { + background-position: 100% 0%; + animation: pulse 0.7s; + } +} + +@keyframes pulse { + 0% { + transform: scale(1.03) translate3d(0, 0, 0) perspective(1px); + } + 20% { + transform: scale(1.2) translate3d(0, 0, 0) perspective(1px); + } + 40% { + transform: scale(1) translate3d(0, 0, 0) perspective(1px); + } + 60% { + transform: scale(1.1) translate3d(0, 0, 0) perspective(1px); + } + 100% { + transform: scale(1) translate3d(0, 0, 0) perspective(1px); + } +} diff --git a/frontend/styling/colors.css b/frontend/styling/colors.css new file mode 100644 index 000000000..6a948d4c2 --- /dev/null +++ b/frontend/styling/colors.css @@ -0,0 +1,36 @@ +:root { + --theme-green: #5cf9fd; + --theme-pink: #d63bc6; + + --background-backup-color: #0e0720; + --background-overlay-color: rgb(41 26 70 / 71%); + + --primary-text-color: #ffffff; + --tagline-text-color: var(--theme-green); + --secondary-text-color: #898591; + --primary-text-disabled-color: #888888; + --secondary-text-disabled-color: #2e7d7f; + + --button-text-color: #3e2f50; + --button-gradient-1-color-1: #f5f5f5; + --button-gradient-1-color-2: var(--theme-green); + --button-gradient-1-color-3: var(--theme-pink); + --button-shadow-color: #5cf9fd47; + --button-disabled-background-color: #a2a2a2; + + --server-selector-border-color: #625b6b; + --server-selector-hover-border-color: var(--theme-green); + --server-selector-background-color: #251b32; + --server-selector-hover-background-color: var(--server-selector-border-color); + + --gauge-background-color: #3e2f50; + --gauge-progress-color: #726c7a; + --gauge-pointer-green: #e2fbfc; + --gauge-pointer-pink: #d091ca; + + --ping-and-jitter-primary-text-color: #f5f5f5; + --ping-and-jitter-secondary-text-color: #7b7b7b; + + --popup-background-color: #251b32; + --popup-shadow-color: #000000; +} diff --git a/frontend/styling/dialog.css b/frontend/styling/dialog.css new file mode 100644 index 000000000..cfbfe03f3 --- /dev/null +++ b/frontend/styling/dialog.css @@ -0,0 +1,132 @@ +/* Styling for the popups */ + +dialog { + flex-direction: column; + align-items: center; + justify-content: center; + width: 70vw; + height: 70vh; + margin: auto; + margin-top: 23rem; + + background: var(--popup-background-color); + border: none; + border-radius: 0.8rem; + + @media screen and (max-width: 800px) { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + max-width: 100vw; /* We need these overrides of browser defaults*/ + max-height: 100vh; + width: auto; + height: auto; + margin: 0; + } + + animation: fade-out 0.3s ease-out; + &[open] { + display: flex; + animation: fade-in 0.3s ease-out; + } + + & > .close-dialog { + display: flex; + align-items: center; + justify-content: center; + width: 4rem; + height: 4rem; + position: absolute; + top: 3rem; + right: 3rem; + + cursor: pointer; + } + + & > section { + max-width: 800px; + overflow-y: auto; + margin: 4rem 2rem 2rem 4rem; + padding: 0 2rem 0 0; + + & h1, + & h2 { + margin: 3rem 0 2rem 0; + font-size: 3.6rem; + font-weight: 400; + letter-spacing: -0.2rem; + color: var(--primary-text-color); + } + & h2 { + margin: 2rem 0 1rem 0; + font-size: 2.5rem; + } + + & p, + & li { + margin: 1rem 0 1rem 0; + font-size: 1.6rem; + line-height: 2.5rem; + font-weight: 400; + letter-spacing: -0.1rem; + color: var(--secondary-text-color); + } + + & ul { + list-style-position: inside; + margin: 1rem; + + & li { + margin: 0.1rem 0; + } + } + + & a { + font-size: 1.6rem; + font-weight: 700; + letter-spacing: -0.1rem; + color: var(--secondary-text-color); + text-underline-offset: 0.3rem; + transition: text-underline-offset 0.2s; + + &:hover { + color: var(--theme-green); + text-underline-offset: 0.5rem; + } + } + } +} + +@keyframes fade-in { + 0% { + opacity: 0; + transform: scale(0.6); + display: none; + } + 0.1% { + display: flex; + } + 100% { + opacity: 1; + transform: scale(1); + display: flex; + } +} + +@keyframes fade-out { + 0% { + opacity: 1; + transform: scale(1); + display: flex; + } + 99.9% { + display: flex; + } + 100% { + opacity: 0; + transform: scale(0.6); + display: none; + } +} diff --git a/frontend/styling/fonts.css b/frontend/styling/fonts.css new file mode 100644 index 000000000..abd7203d3 --- /dev/null +++ b/frontend/styling/fonts.css @@ -0,0 +1,22 @@ +/* latin-ext */ +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url(../fonts/Inter-latin-ext.woff2) format("woff2"); + unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, + U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +/* latin */ +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url(../fonts/Inter-latin.woff2) format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, + U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, + U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/frontend/styling/index.css b/frontend/styling/index.css new file mode 100644 index 000000000..d8abd9c0d --- /dev/null +++ b/frontend/styling/index.css @@ -0,0 +1,85 @@ +/** + * Design by fromScratch Studio - 2022, 2023 (fromscratch.io) + * Implementation in HTML/CSS/JS by Timendus - 2024 (https://github.com/Timendus) + * + * See https://github.com/librespeed/speedtest/issues/585 + */ + +@import url("colors.css"); +@import url("fonts.css"); +@import url("main.css"); +@import url("server-selector.css"); +@import url("button.css"); +@import url("results.css"); +@import url("dialog.css"); + +/* Setting up the basic structure */ + +* { + box-sizing: border-box; + padding: 0; + margin: 0; +} + +html, +body { + min-height: 100vh; + width: 100vw; +} + +html { + background-color: var(--background-backup-color); + background-image: url("../images/background.jpeg"); + background-repeat: no-repeat; + background-position: center; + background-size: cover; + font-size: 10px; + + @media screen and (max-width: 800px) { + font-size: 8px; + } +} + +body { + font-family: "Inter", sans-serif; + background-color: var(--background-overlay-color); + color: var(--primary-text-color); + display: flex; + flex-direction: column; +} + +/* Position the logo */ + +header { + padding: 4rem 7rem; + + @media screen and (max-width: 800px) { + padding: 7rem 2rem; + text-align: center; + } +} + +/* Position the source code link */ + +footer { + margin: auto auto 0 auto; + padding: 5rem; + + & > p.source a { + font-size: 1.6rem; + font-weight: 700; + letter-spacing: -0.1rem; + color: var(--theme-green); + text-underline-offset: 0.3rem; + transition: text-underline-offset 0.2s; + + &:hover { + color: var(--theme-pink); + text-underline-offset: 0.5rem; + } + } + + @media screen and (max-width: 800px) { + padding: 4rem; + } +} diff --git a/frontend/styling/main.css b/frontend/styling/main.css new file mode 100644 index 000000000..530ed4da9 --- /dev/null +++ b/frontend/styling/main.css @@ -0,0 +1,58 @@ +/* Texts on the front page */ + +main { + text-align: center; + padding: 0 2rem; + flex: 1; + + & > h1 { + margin: 0.2rem; + font-size: 3.6rem; + font-weight: 400; + letter-spacing: -0.2rem; + color: var(--primary-text-color); + } + + & p { + margin-top: 8rem; + font-size: 1.6rem; + line-height: 2.5rem; + font-weight: 400; + letter-spacing: -0.1rem; + color: var(--secondary-text-color); + + &#privacy-warning { + min-height: 5.3rem; + + & > span { + font-weight: 700; + color: var(--theme-green); + } + &.hidden { + opacity: 0; + pointer-events: none; + } + } + } + + & > p.tagline { + margin-top: 0; + margin-bottom: 6rem; + font-size: 2rem; + color: var(--tagline-text-color); + } + + & a { + font-size: 1.6rem; + font-weight: 700; + letter-spacing: -0.1rem; + color: var(--secondary-text-color); + text-underline-offset: 0.3rem; + transition: text-underline-offset 0.2s; + + &:hover { + color: var(--theme-green); + text-underline-offset: 0.5rem; + } + } +} diff --git a/frontend/styling/results.css b/frontend/styling/results.css new file mode 100644 index 000000000..55a281e7d --- /dev/null +++ b/frontend/styling/results.css @@ -0,0 +1,260 @@ +/* Variables */ + +:root { + --gauge-width: 32rem; + --gauge-height: 22rem; + --progress-width: 0.6rem; + --speed-width: 3rem; +} + +/* Layout for the gauges */ + +.gauge-layout { + display: flex; + flex-direction: row; + align-items: start; + justify-content: center; + gap: 5rem; + margin: 5rem auto 3rem auto; + + @media screen and (max-width: 1100px) { + display: grid; + grid-template-areas: + "download upload" + "ping jitter"; + justify-items: center; + justify-content: center; + + --gauge-width: min(40vw, 32rem); + --gauge-height: min(28vw, 22rem); + --progress-width: min(1.2vw, 0.6rem); + --speed-width: min(4vw, 3rem); + } + @media screen and (max-width: 500px) { + gap: 5rem 2rem; + } +} + +/* The download/upload speed gauges */ + +/** + * One thing I should really document here is the weird `transform: scale(1);` + * and `position: fixed` in this code. This is a nasty little trick to allow the + * gauge pointer to break out of the `overflow: hidden` of the .speed element. + * We need the `overflow: hidden` to hide the arc that's rotating into view when + * the value goes up. But we do want to see the full pointer, even when it's at + * zero. This degrades fairly gracefully into showing half of the pointer when + * browsers don't understand this. + * + * Trick taken from this article: + * https://medium.com/@thomas.ryu/css-overriding-the-parents-overflow-hidden-90c75a0e7296 + */ + +div.gauge { + position: relative; + transform: scale(1); + width: var(--gauge-width); + height: var(--gauge-height); + + &.download { + grid-area: download; + } + &.upload { + grid-area: upload; + } + + & > .progress, + & > .speed { + position: absolute; + top: 0; + left: 0; + width: var(--gauge-width); + height: calc(var(--gauge-width) / 2); + overflow: hidden; + + &:after, + &:before { + content: ""; + position: absolute; + box-sizing: border-box; + } + } + + & > .progress { + &:before, + &:after { + top: 0; + left: 0; + width: var(--gauge-width); + height: calc(var(--gauge-width) / 2); + + border-radius: 50% 50% 0 0 / 100% 100% 0 0; + border: var(--progress-width) solid var(--gauge-background-color); + border-bottom: 0; + + transform-origin: bottom center; + transform: rotate(var(--progress-rotation)); + transition: transform 0.2s linear; + } + &:after { + top: calc(var(--gauge-width) / 2); + + border-radius: 0 0 50% 50% / 0 0 100% 100%; + border: var(--progress-width) solid var(--gauge-background-color); + border-top: 0; + + transform-origin: top center; + } + } + + & > .speed { + &:before, + &:after { + transform: rotate(var(--speed-rotation)); + transition: transform 0.2s ease; + transition-timing-function: cubic-bezier(0.56, 0.04, 0.59, 0.91); + } + &:before { + position: fixed; + top: calc(var(--gauge-width) / 2 - var(--speed-width) / 3); + left: var(--progress-width); + width: 0; + height: 0; + + border-top: calc(var(--speed-width) / 3) solid transparent; + border-bottom: calc(var(--speed-width) / 3) solid transparent; + border-right: calc(var(--speed-width) * 0.97) solid + var(--gauge-background-color); + z-index: 1; + + transform-origin: calc(var(--gauge-width) / 2 - var(--progress-width)) + calc(var(--speed-width) / 3); + } + &:after { + top: calc(var(--gauge-width) / 2); + left: calc(var(--progress-width) - 0.1rem); + width: calc(var(--gauge-width) - var(--progress-width) * 2 + 0.2rem); + height: calc(var(--gauge-width) / 2 - var(--progress-width) + 0.1rem); + + border-radius: 0 0 50% 50% / 0 0 100% 100%; + border: var(--speed-width) solid var(--gauge-background-color); + border-top: 0; + + transform-origin: top center; + } + } + + &.enabled { + &.download { + & > .progress:after { + border-color: var(--theme-pink); + } + & > .speed { + &:before { + border-right-color: var(--gauge-pointer-pink); + } + &:after { + border-color: var(--theme-pink); + } + } + } + &.upload { + & > .progress:after { + border-color: var(--theme-green); + } + & > .speed { + &:before { + border-right-color: var(--gauge-pointer-green); + } + &:after { + border-color: var(--theme-green); + } + } + } + & > h1 > span { + color: var(--primary-text-color); + } + } + + & > h1, + & > h2 { + display: block; + position: absolute; + width: 100%; + font-family: "Inter", sans-serif; + font-size: 2.1rem; + letter-spacing: -0.1rem; + color: var(--secondary-text-color); + } + & > h1 { + bottom: calc(var(--gauge-height) - var(--gauge-width) / 2); + font-weight: 300; + + & > span { + font-size: 5.5rem; + font-weight: 200; + display: block; + color: var(--secondary-text-color); + letter-spacing: -0.3rem; + } + } + & > h2 { + bottom: 0; + font-weight: 700; + text-transform: uppercase; + } + + @media screen and (max-width: 500px) { + & > h1 { + font-size: 3vw; + + & > span { + font-size: 8vw; + } + } + + & > h2 { + font-size: 3vw; + } + } +} + +/* Styling for Ping and Jitter */ + +.ping, +.jitter { + grid-area: jitter; + display: flex; + align-items: end; + height: calc(var(--gauge-width) / 2); + width: 13rem; + + font-size: 2.1rem; + letter-spacing: -0.1rem; + font-weight: 300; + color: var(--ping-and-jitter-secondary-text-color); + + & > .label { + font-weight: 700; + } + & > .value { + color: var(--ping-and-jitter-primary-text-color); + } + + &.hidden { + display: none; + } + + @media screen and (max-width: 1100px) { + width: 100%; + height: auto; + justify-content: center !important; + } + @media screen and (max-width: 500px) { + font-size: 1.8rem; + } +} +.ping { + grid-area: ping; + justify-content: end; +} diff --git a/frontend/styling/server-selector.css b/frontend/styling/server-selector.css new file mode 100644 index 000000000..d4906c8f4 --- /dev/null +++ b/frontend/styling/server-selector.css @@ -0,0 +1,171 @@ +/* The server selector fake dropdown */ + +.server-selector { + position: relative; + width: 50rem; + margin: 0rem auto; + display: none; + + &.active { + display: block; + } + + @media screen and (max-width: 500px) { + width: 100%; + } + + & > .chosen { + position: relative; + height: 8.8rem; + + border: 1px solid var(--server-selector-border-color); + border-radius: 0.8rem; + background-color: var(--server-selector-background-color); + cursor: pointer; + transition: border-color 0.2s; + + &:hover { + border-color: var(--server-selector-hover-border-color); + } + + & > div.chevron { + content: ""; + position: absolute; + display: block; + width: 32; + height: 32; + right: 1.8rem; + top: 1rem; + } + + & > p { + margin: 0; + position: absolute; + left: 2.4rem; + top: 1.5rem; + font-size: 1.6rem; + font-weight: 400; + letter-spacing: -0.1rem; + color: var(--theme-green); + } + + & > h2 { + position: absolute; + left: 2.4rem; + right: 2.4rem; + bottom: 1rem; + + font-size: 2.4rem; + font-weight: 700; + letter-spacing: -0.2rem; + color: var(--primary-text-color); + text-align: left; + text-transform: uppercase; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + + & span { + font-weight: 400; + } + } + } + + /* Special case for when we have only one server */ + &.single-server { + & > .chosen { + cursor: default; + &:hover { + border-color: var(--server-selector-border-color); + } + & > div.chevron { + display: none; + } + } + } + + /* Overrides for when the test is running and the selector is disabled */ + &.disabled { + pointer-events: none; + + & > .chosen { + cursor: default; + &:hover { + border-color: var(--server-selector-border-color); + } + & > p { + color: var(--secondary-text-disabled-color); + } + & > h2 { + color: var(--primary-text-disabled-color); + } + } + } + + /* Styling for the list of servers that pops out */ + & > ul.servers { + position: absolute; + width: 50rem; + max-height: 70vh; + overflow-y: auto; + z-index: 1; + + border: 1px solid var(--server-selector-border-color); + border-radius: 0.8rem; + background-color: var(--server-selector-background-color); + list-style: none; + + transform: scaleY(0); + transform-origin: top; + transition: transform 0.1s; + + &.active { + transform: scaleY(1); + } + + @media screen and (max-width: 800px) { + width: 100%; + } + + & > li { + &:first-child a { + padding-top: 1.5rem; + } + &:last-child a { + padding-bottom: 1.5rem; + } + + & a { + display: block; + padding: 0.7rem 2.4rem; + + font-size: 2.4rem; + font-weight: 700; + letter-spacing: -0.2rem; + color: var(--sprint-text-color); + text-transform: uppercase; + text-decoration: none; + text-align: left; + cursor: pointer; + + transition: background-color 0.2s; + + & span { + font-weight: 400; + } + &:hover { + background-color: var(--server-selector-hover-background-color); + } + } + } + } + + /* Styling for the sponsor text under the dropdown */ + & > p.sponsor { + margin: 1rem 0 5rem 0; + + & a { + font-weight: 400; + } + } +}