Compare commits

...

7 Commits

18 changed files with 2479 additions and 2299 deletions

4
.gitignore vendored
View File

@ -1,8 +1,10 @@
# generated
assets/
tmp/
speedtester
speed-tester
*.db
view/**/*_templ.go
view/**/*_templ.txt
# dependencies
node_modules/

View File

@ -6,7 +6,7 @@ import (
"log"
"time"
"gitea.zokki.net/zokki/speedtester/models"
"gitea.zokki.net/zokki/speed-tester/models"
_ "github.com/mattn/go-sqlite3" // Import the SQLite driver
"github.com/showwin/speedtest-go/speedtest"
)
@ -120,7 +120,8 @@ func InsertServer(server *speedtest.Server) {
CheckError("insert into speedData", err)
}
func GetAllData(ctx context.Context) ([]models.InternetData, error) {
func GetAllData(ctx context.Context, from string, to string) ([]models.InternetData, error) {
whereFilter, whereValues := whereStatement("CreationDate", from, to)
rows, err := DB.QueryContext(ctx, `SELECT
ID,
CreationDate,
@ -140,7 +141,7 @@ func GetAllData(ctx context.Context) ([]models.InternetData, error) {
ULSpeed,
TestDuration,
PacketLoss
FROM internetData;`)
FROM internetData `+whereFilter, whereValues...)
if err != nil {
return nil, err
}
@ -161,6 +162,91 @@ func GetAllData(ctx context.Context) ([]models.InternetData, error) {
return data, nil
}
func GetSpeedData(ctx context.Context, from string, to string) ([]models.InternetData, error) {
whereFilter, whereValues := whereStatement("CreationDate", from, to)
rows, err := DB.QueryContext(ctx, `SELECT
ID,
CreationDate,
DLSpeed,
ULSpeed
FROM internetData `+whereFilter, whereValues...)
if err != nil {
return nil, err
}
defer rows.Close()
data := []models.InternetData{}
for rows.Next() {
internetData := models.InternetData{}
err = rows.Scan(&internetData.ID, &internetData.CreationDate, &internetData.DLSpeed, &internetData.ULSpeed)
if err != nil {
return nil, err
}
data = append(data, internetData)
}
return data, nil
}
func GetLatencyData(ctx context.Context, from string, to string) ([]models.InternetData, error) {
whereFilter, whereValues := whereStatement("CreationDate", from, to)
rows, err := DB.QueryContext(ctx, `SELECT
ID,
CreationDate,
Latency,
MaxLatency,
MinLatency,
Jitter
FROM internetData `+whereFilter, whereValues...)
if err != nil {
return nil, err
}
defer rows.Close()
data := []models.InternetData{}
for rows.Next() {
internetData := models.InternetData{}
err = rows.Scan(&internetData.ID, &internetData.CreationDate, &internetData.Latency, &internetData.MaxLatency, &internetData.MinLatency, &internetData.Jitter)
if err != nil {
return nil, err
}
data = append(data, internetData)
}
return data, nil
}
func GetDurationData(ctx context.Context, from string, to string) ([]models.InternetData, error) {
whereFilter, whereValues := whereStatement("CreationDate", from, to)
rows, err := DB.QueryContext(ctx, `SELECT
ID,
CreationDate,
TestDuration
FROM internetData `+whereFilter, whereValues...)
if err != nil {
return nil, err
}
defer rows.Close()
data := []models.InternetData{}
for rows.Next() {
internetData := models.InternetData{}
err = rows.Scan(&internetData.ID, &internetData.CreationDate, &internetData.TestDuration)
if err != nil {
return nil, err
}
data = append(data, internetData)
}
return data, nil
}
func GetAllErrors(ctx context.Context) ([]models.Error, error) {
rows, err := DB.QueryContext(ctx, `SELECT
ID,

23
database/helper.go Normal file
View File

@ -0,0 +1,23 @@
package database
func whereStatement(dateCol string, from string, to string) (string, []any) {
whereValues := []any{}
if from == "" && to == "" {
return "", whereValues
}
statement := "WHERE "
if from != "" {
statement += dateCol + " >= date(?) "
whereValues = append(whereValues, from)
}
if from != "" && to != "" {
statement += " AND "
}
if to != "" {
statement += dateCol + " <= date(?)"
whereValues = append(whereValues, to)
}
return statement, whereValues
}

2
go.mod
View File

@ -1,4 +1,4 @@
module gitea.zokki.net/zokki/speedtester
module gitea.zokki.net/zokki/speed-tester
go 1.23.2

View File

@ -1,31 +1,116 @@
import Chart from 'chart.js/auto';
import Chart, { ChartOptions } from 'chart.js/auto';
import zoomPlugin from 'chartjs-plugin-zoom';
fetch('/data')
.then(res => res.json())
.then(json => {
const downloadData = json.map(j => j.DLSpeed / 125000.0); // Mbps
const uploadData = json.map(j => j.ULSpeed / 125000.0); // Mbps
const time = json.map(j => j.CreationDate);
const latencyData = json.map(j => j.Latency / 1000000); // ms
const maxLatencyData = json.map(j => j.MaxLatency / 1000000); // ms
const minLatencyData = json.map(j => j.MinLatency / 1000000); // ms
const jitterData = json.map(j => j.Jitter / 1000000); // ms
const durationData = json.map(j => j.TestDuration / 1000000000); // sec
Chart.register(zoomPlugin);
new Chart(document.querySelector('#up-down-chart') as any, {
type: 'line',
data: {
const lastDay = new Date();
lastDay.setDate(lastDay.getDate() - 1);
const lastHours = `${lastDay.getFullYear()}-${lastDay.getMonth() + 1}-${String(lastDay.getDate()).padStart(2, '0')}`;
const options = {
plugins: {
zoom: {
zoom: {
drag: { enabled: true },
mode: 'x',
},
},
},
} satisfies ChartOptions<'line'>;
const speedChart = new Chart(document.querySelector('#speed-chart') as any, {
type: 'line',
data: { datasets: [] },
options,
});
const latencyChart = new Chart(document.querySelector('#latency-chart') as any, {
type: 'line',
data: { datasets: [] },
options,
});
const durationChart = new Chart(document.querySelector('#duration-chart') as any, {
type: 'line',
data: { datasets: [] },
options,
});
document.querySelectorAll('.reset-chart').forEach(el => {
const chartCanvas = getCanvas(el);
if (!chartCanvas) return;
el.addEventListener('click', () => Chart.getChart(chartCanvas)?.resetZoom('none'));
});
document.querySelectorAll('.last-hours').forEach(el => {
const canvasId = getCanvas(el)?.id;
if (!canvasId) return;
el.addEventListener('click', () => {
switch (canvasId) {
case 'speed-chart':
return updateSpeedChart(lastHours);
case 'latency-chart':
return updateLatencyChart(lastHours);
case 'duration-chart':
return updateDurationChart(lastHours);
default:
return console.warn('[WARNING] `canvasId` not recognized', canvasId);
}
});
});
document.querySelectorAll('.complete-data').forEach(el => {
const canvasId = getCanvas(el)?.id;
if (!canvasId) return;
el.addEventListener('click', () => {
switch (canvasId) {
case 'speed-chart':
return updateSpeedChart();
case 'latency-chart':
return updateLatencyChart();
case 'duration-chart':
return updateDurationChart();
default:
return console.warn('[WARNING] `canvasId` not recognized', canvasId);
}
});
});
updateSpeedChart(lastHours);
updateLatencyChart(lastHours);
updateDurationChart(lastHours);
function updateSpeedChart(from?: string, to?: string): void {
fetch('/data/speed' + toUrlQuery(from, to))
.then(res => res.json())
.then(json => {
const downloadData = json.map(j => j.DLSpeed / 125000.0); // Mbps
const uploadData = json.map(j => j.ULSpeed / 125000.0); // Mbps
const time = json.map(j => j.CreationDate);
speedChart.data = {
labels: time,
datasets: [
{ data: downloadData, label: 'Download' },
{ data: uploadData, label: 'Upload' },
],
},
};
speedChart.update('none');
speedChart.resetZoom('none');
});
}
new Chart(document.querySelector('#latency-chart') as any, {
type: 'line',
data: {
function updateLatencyChart(from?: string, to?: string): void {
fetch('/data/latency' + toUrlQuery(from, to))
.then(res => res.json())
.then(json => {
const latencyData = json.map(j => j.Latency / 1000000); // ms
const maxLatencyData = json.map(j => j.MaxLatency / 1000000); // ms
const minLatencyData = json.map(j => j.MinLatency / 1000000); // ms
const jitterData = json.map(j => j.Jitter / 1000000); // ms
const time = json.map(j => j.CreationDate);
latencyChart.data = {
labels: time,
datasets: [
{ data: latencyData, label: 'Latency' },
@ -33,14 +118,32 @@ fetch('/data')
{ data: minLatencyData, label: 'min Latency' },
{ data: jitterData, label: 'Jitter' },
],
},
};
latencyChart.update('none');
latencyChart.resetZoom('none');
});
}
new Chart(document.querySelector('#duration-chart') as any, {
type: 'line',
data: {
function updateDurationChart(from?: string, to?: string): void {
fetch('/data/duration' + toUrlQuery(from, to))
.then(res => res.json())
.then(json => {
const durationData = json.map(j => j.TestDuration / 1000000000); // sec
const time = json.map(j => j.CreationDate);
durationChart.data = {
labels: time,
datasets: [{ data: durationData, label: 'Duration' }],
},
};
durationChart.update('none');
durationChart.resetZoom('none');
});
});
}
function toUrlQuery(from?: string, to?: string): string {
return `?from=${from ?? ''}&to=${to ?? ''}`;
}
function getCanvas(element: Element): HTMLCanvasElement | undefined {
return element.closest('.wrapper')?.querySelector('canvas') ?? undefined;
}

16
main.go
View File

@ -4,14 +4,18 @@ import (
"embed"
"log"
"net/http"
"os"
"strings"
"time"
"gitea.zokki.net/zokki/speedtester/database"
"gitea.zokki.net/zokki/speedtester/routes"
"gitea.zokki.net/zokki/speed-tester/database"
"gitea.zokki.net/zokki/speed-tester/routes"
_ "github.com/mattn/go-sqlite3" // Import the SQLite driver
"github.com/showwin/speedtest-go/speedtest"
)
var isDev = strings.ToUpper(os.Getenv("ENVIRONMENT")) == "DEVELOPMENT"
//go:embed assets
var assetFS embed.FS
@ -34,8 +38,12 @@ func main() {
router := http.NewServeMux()
router.HandleFunc("/", routes.Routes())
// router.Handle("/assets/", http.StripPrefix("/assets", http.FileServer(http.Dir("assets"))))
router.Handle("/assets/", http.FileServer(http.FS(assetFS)))
if isDev {
router.Handle("/assets/", http.StripPrefix("/assets", http.FileServer(http.Dir("assets"))))
} else {
router.Handle("/assets/", http.FileServer(http.FS(assetFS)))
}
server := http.Server{
Addr: ":9512",

View File

@ -1,22 +1,22 @@
package models
type InternetData struct {
ID uint64
CreationDate string
URL string
Lat string
Lon string
Name string
Country string
Sponsor string
Host string
Distance float64
Latency uint64
MaxLatency uint64
MinLatency uint64
Jitter uint64
DLSpeed float64
ULSpeed float64
TestDuration uint64
PacketLoss float64
ID uint64 `json:"ID,omitempty"`
CreationDate string `json:"CreationDate,omitempty"`
URL string `json:"URL,omitempty"`
Lat string `json:"Lat,omitempty"`
Lon string `json:"Lon,omitempty"`
Name string `json:"Name,omitempty"`
Country string `json:"Country,omitempty"`
Sponsor string `json:"Sponsor,omitempty"`
Host string `json:"Host,omitempty"`
Distance float64 `json:"Distance,omitempty"`
Latency uint64 `json:"Latency,omitempty"`
MaxLatency uint64 `json:"MaxLatency,omitempty"`
MinLatency uint64 `json:"MinLatency,omitempty"`
Jitter uint64 `json:"Jitter,omitempty"`
DLSpeed float64 `json:"DLSpeed,omitempty"`
ULSpeed float64 `json:"ULSpeed,omitempty"`
TestDuration uint64 `json:"TestDuration,omitempty"`
PacketLoss float64 `json:"PacketLoss,omitempty"`
}

4184
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
{
"devDependencies": {
"chart.js": "^4.4.6",
"chartjs-plugin-zoom": "^2.0.1",
"esbuild": "^0.24.0",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.8",

View File

@ -1,12 +1,10 @@
package routes
import (
"encoding/json"
"net/http"
"strings"
"gitea.zokki.net/zokki/speedtester/database"
"gitea.zokki.net/zokki/speedtester/view"
"gitea.zokki.net/zokki/speed-tester/view"
)
type Route struct {
@ -30,19 +28,21 @@ func Routes() func(http.ResponseWriter, *http.Request) {
SubRoutes: []*Route{
{
Path: "/data",
Get: func(writer http.ResponseWriter, req *http.Request) {
data, err := database.GetAllData(req.Context())
if err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
Get: allRoute,
SubRoutes: []*Route{
json, err := json.Marshal(data)
if err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
writer.Write(json)
{
Path: "/speed",
Get: speedRoute,
},
{
Path: "/latency",
Get: latencyRoute,
},
{
Path: "/duration",
Get: durationRoute,
},
},
},
},

47
routes/data.go Normal file
View File

@ -0,0 +1,47 @@
package routes
import (
"encoding/json"
"net/http"
"gitea.zokki.net/zokki/speed-tester/database"
"gitea.zokki.net/zokki/speed-tester/models"
)
func allRoute(writer http.ResponseWriter, req *http.Request) {
query := req.URL.Query()
data, err := database.GetAllData(req.Context(), query.Get("from"), query.Get("to"))
handleData(writer, data, err)
}
func speedRoute(writer http.ResponseWriter, req *http.Request) {
query := req.URL.Query()
data, err := database.GetSpeedData(req.Context(), query.Get("from"), query.Get("to"))
handleData(writer, data, err)
}
func latencyRoute(writer http.ResponseWriter, req *http.Request) {
query := req.URL.Query()
data, err := database.GetLatencyData(req.Context(), query.Get("from"), query.Get("to"))
handleData(writer, data, err)
}
func durationRoute(writer http.ResponseWriter, req *http.Request) {
query := req.URL.Query()
data, err := database.GetDurationData(req.Context(), query.Get("from"), query.Get("to"))
handleData(writer, data, err)
}
func handleData(writer http.ResponseWriter, data []models.InternetData, err error) {
if err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
json, err := json.Marshal(data)
if err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
writer.Write(json)
}

View File

@ -3,8 +3,8 @@ package view
import (
"context"
"fmt"
"gitea.zokki.net/zokki/speedtester/database"
view "gitea.zokki.net/zokki/speedtester/view/shared"
"gitea.zokki.net/zokki/speed-tester/database"
view "gitea.zokki.net/zokki/speed-tester/view/shared"
)
templ Index(ctx context.Context) {
@ -12,9 +12,30 @@ templ Index(ctx context.Context) {
errors, _ := database.GetAllErrors(ctx)
}}
@view.Layout() {
<canvas id="up-down-chart" class="basis-5/12 flex-shrink-0"></canvas>
<canvas id="latency-chart" class="basis-5/12 flex-shrink-0"></canvas>
<canvas id="duration-chart" class="basis-5/12 flex-shrink-0"></canvas>
<div class="wrapper flex flex-col gap-2">
<canvas id="speed-chart"></canvas>
<div class="flex gap-4">
<button class="reset-chart primary">Reset zoom</button>
<button class="last-hours secondary">last hours</button>
<button class="complete-data secondary">complete data</button>
</div>
</div>
<div class="wrapper flex flex-col gap-2">
<canvas id="latency-chart"></canvas>
<div class="flex gap-4">
<button class="reset-chart primary">Reset zoom</button>
<button class="last-hours secondary">last hours</button>
<button class="complete-data secondary">complete data</button>
</div>
</div>
<div class="wrapper flex flex-col gap-2">
<canvas id="duration-chart"></canvas>
<div class="flex gap-4">
<button class="reset-chart primary">Reset zoom</button>
<button class="last-hours secondary">last hours</button>
<button class="complete-data secondary">complete data</button>
</div>
</div>
<ul>
<caption>Errors:</caption>
for _, error := range errors {

View File

@ -1,91 +0,0 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.2.778
package view
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"context"
"fmt"
"gitea.zokki.net/zokki/speedtester/database"
view "gitea.zokki.net/zokki/speedtester/view/shared"
)
func Index(ctx context.Context) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
errors, _ := database.GetAllErrors(ctx)
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 1)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, error := range errors {
log := fmt.Sprintf("[%s] (%s): %s", error.CreationDate, error.Detail, error.Error)
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 2)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(log)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/index.templ`, Line: 22, Col: 13}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 4)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return templ_7745c5c3_Err
})
templ_7745c5c3_Err = view.Layout().Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return templ_7745c5c3_Err
})
}
var _ = templruntime.GeneratedTemplate

View File

@ -1,4 +0,0 @@
<canvas id=\"up-down-chart\" class=\"basis-5/12 flex-shrink-0\"></canvas><canvas id=\"latency-chart\" class=\"basis-5/12 flex-shrink-0\"></canvas><canvas id=\"duration-chart\" class=\"basis-5/12 flex-shrink-0\"></canvas><ul><caption>Errors:</caption>
<li>
</li>
</ul>

View File

@ -7,8 +7,8 @@ templ Layout() {
<meta charset="utf-8"/>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="stylesheet" href="/assets/global.css"/>
<link rel="stylesheet" href="/assets/styles.css"/>
<link rel="stylesheet" href="/assets/global.css"/>
<script src="/assets/index.js" defer></script>
<title>Internet Speed</title>
</head>

View File

@ -1,48 +0,0 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.2.778
package view
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func Layout() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 1)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 2)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return templ_7745c5c3_Err
})
}
var _ = templruntime.GeneratedTemplate

View File

@ -1,2 +0,0 @@
<!doctype html><html lang=\"de\" class=\"bg-background\"><head><meta charset=\"utf-8\"><meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><link rel=\"stylesheet\" href=\"/assets/global.css\"><link rel=\"stylesheet\" href=\"/assets/styles.css\"><script src=\"/assets/index.js\" defer></script><title>Internet Speed</title></head><body><main>
</main></body></html>

View File

@ -159,7 +159,9 @@ body {
}
main {
@apply grid flex-wrap justify-around gap-6 p-4 xl:flex;
@apply grid p-4;
row-gap: var(--main-gap, 2.5rem);
}
@media (prefers-reduced-motion: reduce) {
@ -237,4 +239,12 @@ picture {
button {
@apply flex min-w-fit items-center rounded px-4 py-2;
&.primary {
@apply bg-primary text-on-primary;
}
&.secondary {
@apply bg-secondary text-on-secondary;
}
}