1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
|
package web
templ Layout(title string, content templ.Component) {
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{ title } · biking_home</title>
<style>
:root { color-scheme: light; font-family: system-ui, sans-serif; background: #f5f3ee; color: #1d2a22; }
* { box-sizing: border-box; }
body { margin: 0; }
a { color: #18794e; }
.site-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem max(1rem, calc((100vw - 70rem) / 2)); background: #173b2a; color: #fff; }
.site-header a { color: #fff; text-decoration: none; }
.brand { font-weight: 750; letter-spacing: .02em; }
.nav { display: flex; gap: 1rem; font-size: .95rem; }
.container { width: min(70rem, calc(100% - 2rem)); margin: 0 auto; padding: 2.5rem 0 4rem; }
.eyebrow { margin: 0 0 .4rem; color: #18794e; font-size: .78rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
h1 { margin: 0; font-size: clamp(2rem, 4vw, 3.2rem); line-height: 1; }
.lead { max-width: 42rem; color: #5b675f; }
.panel { margin-top: 2rem; padding: 1.25rem; border: 1px solid #d9ded8; border-radius: 1rem; background: #fff; box-shadow: 0 1rem 2.5rem #173b2a0d; }
.toolbar { display: flex; align-items: end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.button { display: inline-block; border: 0; border-radius: .65rem; padding: .7rem 1rem; background: #d96932; color: #fff; font: inherit; font-weight: 700; text-decoration: none; cursor: pointer; }
.button.secondary { background: #e8eee9; color: #173b2a; }
label { display: grid; gap: .35rem; color: #5b675f; font-size: .85rem; font-weight: 650; }
input { border: 1px solid #c8d0c9; border-radius: .55rem; padding: .65rem .7rem; font: inherit; color: inherit; background: #fff; }
.form-row { display: flex; align-items: end; gap: .75rem; flex-wrap: wrap; }
.notice { margin: 1rem 0 0; border-radius: .6rem; padding: .75rem 1rem; background: #e5f3e9; color: #17633f; }
.error { margin: 1rem 0 0; border-radius: .6rem; padding: .75rem 1rem; background: #fbe8df; color: #8c3517; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: .85rem .5rem; border-bottom: 1px solid #e5e9e5; text-align: left; }
th { color: #68746c; font-size: .78rem; letter-spacing: .08em; text-transform: uppercase; }
.numeric { text-align: right; white-space: nowrap; }
.empty { padding: 2.5rem 1rem; text-align: center; color: #68746c; }
@media (max-width: 700px) { .container { width: min(100% - 1rem, 70rem); padding-top: 1.5rem; } .site-header { padding-inline: 1rem; } th:nth-child(n+4), td:nth-child(n+4) { display: none; } .panel { padding: .8rem; } }
</style>
</head>
<body>
<header class="site-header">
<a class="brand" href="/">biking_home</a>
<nav class="nav"><a href="/">Rides</a><a href="/sync">Sync Strava</a></nav>
</header>
<main class="container">
@content
</main>
</body>
</html>
}
templ RidesPage(items []RideView) {
@Layout("Rides", RidesContent(items))
}
templ RidesContent(items []RideView) {
<p class="eyebrow">Ride library</p>
<div class="toolbar">
<div><h1>All rides</h1><p class="lead">Your imported rides, ready for climb analysis.</p></div>
<a class="button" href="/sync">Sync Strava</a>
</div>
<section class="panel">
if len(items) == 0 {
<div class="empty">No rides stored yet. <a href="/sync">Import your first Strava rides.</a></div>
} else {
<table>
<thead><tr><th>Ride</th><th>Started</th><th class="numeric">Distance</th><th class="numeric">Moving time</th><th class="numeric">Elevation</th><th class="numeric">Cotacol</th><th class="numeric">Cotacol / 100 km</th></tr></thead>
<tbody>
for _, item := range items {
<tr><td><strong>{ item.Name }</strong><br/><small>{ item.Type }</small></td><td>{ formatRideDate(item.StartDate) }</td><td class="numeric">{ formatDistance(item.DistanceM) }</td><td class="numeric">{ formatDuration(item.MovingTimeS) }</td><td class="numeric">{ formatElevation(item.TotalElevationGainM) }</td><td class="numeric">{ item.Cotacol }</td><td class="numeric">{ item.CotacolPer100Km }</td></tr>
}
</tbody>
</table>
}
</section>
}
templ SyncPage(data SyncPageData) {
@Layout("Sync Strava", SyncContent(data))
}
templ SyncContent(data SyncPageData) {
<p class="eyebrow">Data import</p>
<h1>Sync Strava rides</h1>
<p class="lead">Choose a date range. New rides are downloaded as GPX files and recorded in the local library.</p>
if data.Notice != "" {
<div class="notice">{ data.Notice }</div>
}
if data.Error != "" {
<div class="error">{ data.Error }</div>
}
<section class="panel">
if !data.HasAuth {
<p>Strava authorization is required before the first sync.</p>
<a class="button secondary" href={ templ.URL("/strava/login?return_to=/sync") }>Authorize Strava</a>
} else {
<form method="post" action="/sync">
<div class="form-row">
<label>From<input type="date" name="from" value={ data.From } required/></label>
<label>To<input type="date" name="to" value={ data.To } required/></label>
<button class="button" type="submit">Sync rides</button>
</div>
</form>
}
</section>
}
|