feat: new nav with categories & search; add internet speed test and timer test
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
Tomáš Mládek 2024-03-07 16:26:43 +01:00
parent 8e9d6cbe1e
commit 606cc0ca51
7 changed files with 448 additions and 46 deletions

View file

@ -44,13 +44,18 @@ button, .button {
} }
} }
input[type="number"] { input[type="number"], input[type="search"], input[type="text"] {
background: transparent; background: transparent;
color: white; color: white;
border: 1px solid white; border: 1px solid white;
border-radius: 4px; border-radius: 4px;
padding: 0.2em; padding: 0.2em;
&:focus {
outline: solid rgba(255, 255, 255, 0.66);
}
&:disabled { &:disabled {
opacity: 0.7; opacity: 0.7;
pointer-events: none; pointer-events: none;

View file

@ -1,8 +1,14 @@
import i18next from 'i18next'; import i18next from 'i18next';
import { createI18nStore } from 'svelte-i18next'; import { createI18nStore } from 'svelte-i18next';
import enTranslation from './locales/en.json';
i18next.init({ i18next.init({
lng: 'en' lng: 'en',
resources: {
en: {
translation: enTranslation
}
}
}); });
export const i18n = createI18nStore(i18next); export const i18n = createI18nStore(i18next);

49
src/lib/locales/en.json Normal file
View file

@ -0,0 +1,49 @@
{
"tests": {
"audio": {
"label": "Audio",
"description": "Check your stereo channels or surround audio output, verify if your speakers are in phase."
},
"av-sync": {
"label": "Audio/Video Sync",
"description": "Check if your audio and video are in sync, and measure the delay."
},
"card": {
"label": "Card",
"description": "Test card for your display or projector, check colors, resolution and geometry."
},
"camera": {
"label": "Camera",
"description": "Check whether your webcam or capture device is working, its image quality, resolution and frame rate. Take a snapshot."
},
"gamepad": {
"label": "Gamepad",
"description": "Test your gamepad, check if it's working, all the buttons and joysticks, stick drift, dead zones and calibration."
},
"keyboard": {
"label": "Keyboard",
"description": "Check if all keys are working and what key codes they send."
},
"microphone": {
"label": "Microphone",
"description": "Check if your microphone is working, its quality, volume and noise."
},
"mouse": {
"label": "Mouse",
"description": "Check if your mouse or touch device works properly, if there are dead zones or jitter."
},
"sensors": {
"label": "Sensors",
"description": "See the output of your device's sensors, e.g. GPS, accelerometer, gyroscope, compass, etc."
},
"internet": {
"label": "Internet speed",
"description": "Measure your internet speed, ping and jitter."
},
"timer": {
"label": "High resolution timer",
"description": "Display a microsecond resolution timer on screen, useful for measuring video pipeline latency."
}
}
}

View file

@ -17,7 +17,10 @@
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.8); height: 90vh;
width: 90vw;
background: rgba(0, 0, 0, 0.85);
border-radius: 0.5rem; border-radius: 0.5rem;
border: 1px solid white; border: 1px solid white;
@ -27,11 +30,6 @@
flex-direction: column; flex-direction: column;
} }
main.sub {
height: 90vh;
width: 90vw;
}
.button-back { .button-back {
position: absolute; position: absolute;
top: 1rem; top: 1rem;

View file

@ -1,53 +1,243 @@
<script> <script lang="ts">
import { version } from '../../../package.json'; import { version } from '../../../package.json';
import { i18n } from '$lib/i18n'; import { i18n } from '$lib/i18n';
import type { Snapshot } from '@sveltejs/kit';
let search = '';
type Entry = {
id: string;
label: string;
icon: string;
};
type Test = Entry & {
categories: Array<Category>;
disabled?: boolean;
};
type Category = (typeof categories)[number]['id'] | (typeof superCategories)[number]['id'];
let superCategories = [
{
id: 'inputs',
label: 'Inputs',
icon: 'ti-arrow-down-to-arc'
},
{
id: 'outputs',
label: 'Outputs',
icon: 'ti-arrow-down-from-arc'
}
] as const satisfies Entry[];
let categories = [
{
id: 'audio',
label: 'Audio',
icon: 'ti-volume'
},
{
id: 'video',
label: 'Video',
icon: 'ti-video'
},
{
id: 'control',
label: 'Control',
icon: 'ti-hand-finger'
},
{
id: 'misc',
label: 'Miscellaneous',
icon: 'ti-circle-plus'
}
] as const satisfies Entry[];
const tests: Test[] = [
{
id: 'card',
label: 'Test Card',
icon: 'ti-device-desktop',
categories: ['outputs', 'video']
},
{
id: 'audio',
label: 'Audio',
icon: 'ti-volume',
categories: ['outputs', 'audio']
},
{
id: 'av-sync',
label: 'AV Sync',
icon: 'ti-time-duration-off',
categories: ['outputs', 'video', 'audio']
},
{
id: 'keyboard',
label: 'Keyboard',
icon: 'ti-keyboard',
categories: ['inputs', 'control']
},
{
id: 'mouse',
label: 'Mouse',
icon: 'ti-mouse',
categories: ['inputs', 'control']
},
{
id: 'gamepad',
label: 'Gamepad',
icon: 'ti-device-gamepad',
categories: ['inputs', 'control']
},
{
id: 'camera',
label: 'Camera',
icon: 'ti-camera',
categories: ['inputs', 'video']
},
{
id: 'microphone',
label: 'Microphone',
icon: 'ti-microphone',
categories: ['inputs', 'audio'],
disabled: true
},
{
id: 'sensors',
label: 'Sensors',
icon: 'ti-cpu-2',
categories: ['inputs', 'misc'],
disabled: true
},
{
id: 'internet',
label: 'Internet speed',
icon: 'ti-world',
categories: ['inputs', 'outputs', 'misc']
},
{
id: 'timer',
label: 'High resolution timer',
icon: 'ti-alarm',
categories: ['video']
}
];
let filteredTests: Test[] = tests;
let filteredCategories: Category[] = [];
function doSearch(search: string) {
filteredTests = tests.filter((test) => {
if (!search) return true;
const searchValue = search.toLocaleLowerCase();
return (
test.label.includes(searchValue) ||
test.id.includes(searchValue) ||
$i18n.t(`tests.${test.id}.description`).includes(searchValue)
);
});
}
$: doSearch(search);
function setFilter(category: Category) {
if (filteredCategories.includes(category)) {
filteredCategories = filteredCategories.filter((c) => c !== category);
} else {
filteredCategories = [...filteredCategories, category];
}
}
$: nonEmptyCategories = categories.filter((category) => {
const categoryTests = filteredTests.filter((test) => test.categories.includes(category.id));
return categoryTests.some(
(test) =>
!filteredCategories.length || filteredCategories.every((f) => test.categories.includes(f))
);
});
export const snapshot: Snapshot<string> = {
capture: () => JSON.stringify({ filtered: filteredCategories, search }),
restore: (value) => {
const { filtered: restoredFiltered, search: restoredSearch } = JSON.parse(value);
filteredCategories = restoredFiltered;
search = restoredSearch;
}
};
</script> </script>
<h1>Total Tech Test</h1>
<nav> <nav>
<h1>Total Tech Test</h1> <!-- svelte-ignore a11y-autofocus -->
<input type="search" placeholder={$i18n.t('Search')} bind:value={search} autofocus />
<div class="options"> <div class="options">
<a href="card"> {#each superCategories as category}
<i class="ti ti-device-desktop"></i> <button
{$i18n.t('Screen')} on:click={() => setFilter(category.id)}
</a> class:active={!filteredCategories.length || filteredCategories.includes(category.id)}
<a href="audio"> class="super"
<i class="ti ti-volume"></i> >
{$i18n.t('Audio')} <i class="ti {category.icon}"></i>
</a> {$i18n.t(category.label)}
<a href="av-sync"> </button>
<i class="ti ti-time-duration-off"></i> {/each}
{$i18n.t('AV Sync')} <div class="separator"></div>
</a> {#each categories as category}
<a href="keyboard"> <button
<i class="ti ti-keyboard"></i> on:click={() => setFilter(category.id)}
{$i18n.t('Keyboard')} class:active={!filteredCategories.length || filteredCategories.includes(category.id)}
</a> >
<a href="mouse"> <i class="ti {category.icon}"></i>
<i class="ti ti-mouse"></i> {$i18n.t(category.label)}
{$i18n.t('Mouse')} </button>
</a> {/each}
<a href="gamepad"> </div>
<i class="ti ti-device-gamepad"></i> <div class="tests">
{$i18n.t('Gamepad')} {#each nonEmptyCategories as category}
</a> {#if tests.filter((test) => test.categories.includes(category.id)).length > 0}
<a href="camera"> <h2>{$i18n.t(category.label)}</h2>
<i class="ti ti-camera"></i> {#each filteredTests.filter((test) => test.categories.includes(category.id) && filteredCategories.every( (f) => test.categories.includes(f) )) as test}
{$i18n.t('Camera')} <div class="test" class:disabled={test.disabled}>
</a> <a class="test" href={test.id}>
<a href="microphone" class="disabled"> <span class="label">
<i class="ti ti-microphone"></i> <i class="ti {test.icon}"></i>
{$i18n.t('Microphone')} {$i18n.t(test.label)}
</a> </span>
<a href="sensors" class="disabled"> <span class="description">
<i class="ti ti-cpu-2"></i> {$i18n.t(`tests.${test.id}.description`)}
{$i18n.t('Sensors')} </span>
</a> </a>
</div>
{/each}
{/if}
{:else}
<p>
{$i18n.t('No tests found.')}
</p>
{/each}
</div> </div>
</nav> </nav>
<footer><a href="https://git.thm.place/thm/test-card">testcard v{version}</a></footer> <footer><a href="https://git.thm.place/thm/test-card">testcard v{version}</a></footer>
<style> <style>
nav {
display: flex;
flex-direction: column;
gap: 1rem;
flex-grow: 1;
padding: 0 4rem;
overflow: auto;
& input[type='search'] {
padding: 0.5em;
width: 100%;
}
}
h1 { h1 {
text-align: center; text-align: center;
font-size: 3rem; font-size: 3rem;
@ -55,12 +245,36 @@
text-transform: uppercase; text-transform: uppercase;
} }
h2 {
font-weight: normal;
margin: 0.25em 0;
}
.options { .options {
display: flex; display: flex;
justify-content: space-evenly; justify-content: space-evenly;
align-items: center; align-items: center;
gap: 2em; gap: 2em;
& button {
border: none;
background: none;
display: flex;
flex-direction: column;
opacity: 0.66;
&.active {
opacity: 1;
}
}
& .separator {
border-left: 1px solid currentColor;
height: 3rem;
opacity: 0.8;
}
& a { & a {
text-align: center; text-align: center;
text-decoration: none; text-decoration: none;
@ -78,10 +292,35 @@
} }
} }
.test {
display: flex;
gap: 0.5em;
margin-bottom: 0.25em;
& .label {
white-space: nowrap;
}
& .description {
opacity: 0.85;
}
& a {
text-decoration: none;
color: inherit;
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
}
footer { footer {
text-align: center; text-align: center;
opacity: 0.6; opacity: 0.6;
margin-top: 1rem; margin-top: 1rem;
& a { & a {
text-decoration: none; text-decoration: none;
} }

View file

@ -0,0 +1,31 @@
<script>
import { i18n } from '$lib/i18n';
</script>
<h2><i class="ti ti-world"></i> {$i18n.t('Internet speed')}</h2>
<div class="test">
<iframe src="//openspeedtest.com/speedtest" title="OpenSpeedTest Embed"></iframe>
</div>
<div class="attribution">
Provided by <a href="https://openspeedtest.com">OpenSpeedtest.com</a>
</div>
<style>
iframe {
border: none;
flex-grow: 1;
max-height: 50vh;
}
.test {
display: flex;
flex-direction: column;
justify-content: center;
flex-grow: 1;
}
.attribution {
text-align: right;
}
</style>

View file

@ -0,0 +1,74 @@
<script lang="ts">
import { onMount } from 'svelte';
import { i18n } from '$lib/i18n';
let time = 0;
let fps = 0;
let start = 0;
let displayFps = '?';
let fpsInterval: NodeJS.Timeout | undefined;
const times: number[] = [];
function refreshLoop() {
const now = performance.now();
time = Math.floor(now - start);
while (times.length > 0 && times[0] <= now - 1000) {
times.shift();
}
times.push(now);
fps = times.length;
window.requestAnimationFrame(refreshLoop);
}
function restart() {
displayFps = '?';
times.length = 0;
start = performance.now();
clearInterval(fpsInterval);
fpsInterval = setInterval(() => {
displayFps = fps.toString();
}, 1000);
}
onMount(() => {
refreshLoop();
restart();
});
</script>
<h2><i class="ti ti-alarm"></i> {$i18n.t('High resolution timer')}</h2>
<div class="display">
<div class="time">{time}</div>
<div class="fps">{displayFps} {$i18n.t('FPS')}</div>
</div>
<button on:click={restart}>{$i18n.t('Restart')}</button>
<style>
div,
button {
text-align: center;
margin-bottom: 1rem;
user-select: none;
line-height: 1;
}
.display {
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.time {
font-size: 12rem;
}
.fps {
font-size: 4rem;
}
button {
align-self: center;
font-size: 2rem;
}
</style>