1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import "fmt"
6
7// Home is the repository list.
8templ Home(l Layout, p HomePage) {
9 @Shell(l, nil) {
10 <h1 class="sr">Repositories</h1>
11 <div class="home" data-layout="cards">
12 <section class="home-repos" aria-label="Repositories">
13 <div class="home-bar">
14 <div class="filter">
15 <label for="home-filter" class="sr">filter</label>
16 <input
17 id="home-filter"
18 type="search"
19 data-filter="#repo-rows > li"
20 placeholder="Filter repositories…"
21 autocomplete="off"
22 hx-on:input="ermite.filter(this)"
23 />
24 </div>
25 <nav class="sort" aria-label="Sort repositories">
26 <span class="mute">sort</span>
27 for _, s := range p.Sorts {
28 <a
29 href={ templ.SafeURL(s.Href) }
30 if s.Mark != "" {
31 aria-current="true"
32 }
33 >
34 { s.Label }
35 if s.Mark != "" {
36 <span aria-hidden="true">{ s.Mark }</span>
37 <span class="sr">{ ", " + s.State }</span>
38 }
39 </a>
40 }
41 </nav>
42 <div class="layout-switch" role="group" aria-label="Layout" data-js>
43 <button type="button" class="btn icon" data-set-layout="cards" aria-pressed="true" title="Cards" aria-label="Cards">
44 <span class="ico i-cards" aria-hidden="true"></span>
45 </button>
46 <button type="button" class="btn icon" data-set-layout="split" aria-pressed="false" title="Two columns" aria-label="Two columns">
47 <span class="ico i-split" aria-hidden="true"></span>
48 </button>
49 <button type="button" class="btn icon" data-set-layout="compact" aria-pressed="false" title="Compact list" aria-label="Compact list">
50 <span class="ico i-compact" aria-hidden="true"></span>
51 </button>
52 </div>
53 </div>
54 <ul class="repos" id="repo-rows" data-rows data-sort={ p.Sort } data-desc={ fmt.Sprint(p.Desc) }>
55 for _, r := range p.Rows {
56 @HomeRowView(r)
57 }
58 </ul>
59 if len(p.Rows) == 0 {
60 <p class="lede dim">no repositories yet.</p>
61 }
62 </section>
63 </div>
64 }
65}
66
67// Summary is the summary of a repository: the README reads in the main
68// column at the reading measure; what one acts on sits in the rail.
69templ Summary(l Layout, p SummaryPage) {
70 @Shell(l, summaryRail(p)) {
71 if p.Readme != nil {
72 @ReadmeCard(*p.Readme, p.Docs...)
73 }
74 }
75}
76
77// latestTagFact is the "latest tag" row of About: the newest tag (its
78// commit subject on hover), how old it is, and the count of tags, a link to
79// all of them. With too many tags to date them, the count only.
80templ latestTagFact(t LatestTag) {
81 <dt>latest tag</dt>
82 <dd class="latest-tag">
83 if t.Name != "" {
84 <a class="tag-name" href={ templ.SafeURL(t.Href) } title={ t.Subject }>{ t.Name }</a>
85 if t.WhenAt != "" {
86 { " · " }<time datetime={ t.WhenAt } title={ t.WhenAt }>{ t.When }</time>
87 }
88 { " · " }
89 }
90 <a class="all-tags" href={ templ.SafeURL(t.RefsHref) }>{ plural(t.Count, "tag", "tags") }</a>
91 </dd>
92}
93
94templ summaryRail(p SummaryPage) {
95 if p.Mirror != nil {
96 <section aria-labelledby="r-mirror">
97 <h2 id="r-mirror">Mirror</h2>
98 <p>⚠ Mirror { p.Mirror.State }</p>
99 if p.Mirror.Reason != "" {
100 <p>{ p.Mirror.Reason }</p>
101 }
102 <p>Run <code>{ p.Mirror.Command }</code></p>
103 </section>
104 }
105
106 <section aria-labelledby="r-clone"><h2 id="r-clone">Clone</h2>@CloneBlock(p.Clone)
107</section>
108 <section aria-labelledby="r-facts">
109 <h2 id="r-facts">About</h2>
110 <dl class="kv">
111 for _, f := range p.Facts {
112 <dt>{ f.Key }</dt>
113 @FactValue(f)
114 }
115 if lic := p.License; lic != nil {
116 <dt>license</dt>
117 <dd><a href={ templ.SafeURL(lic.Href) }>{ lic.ID }</a></dd>
118 }
119 if p.Latest != nil {
120 @latestTagFact(*p.Latest)
121 }
122 if r := p.Release; r != nil {
123 <dt>release</dt>
124 <dd>
125 <a href={ templ.SafeURL(r.Href) }>{ r.Tag }</a>{ " · " }
126 <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>{ " · " }
127 <a href={ templ.SafeURL(r.AllHref) }>all releases</a>
128 </dd>
129 }
130 </dl>
131 </section>
132 if len(p.Recent) > 0 {
133 <section aria-labelledby="r-recent"><h2 id="r-recent">Recent commits <a href={ templ.SafeURL(p.AllHref) }>View all</a></h2>@CommitRows(p.Recent)
134</section>
135 }
136 if p.Actions != nil {
137 @RecentActionsBlock(*p.Actions)
138 }
139 if len(p.Outline) > 0 && p.Readme != nil {
140 <section aria-labelledby="r-outline"><h2 id="r-outline">On this page</h2>@Outline(p.Outline)
141</section>
142 }
143}
144
145// Refs lists the branches and tags of a repository.
146templ Refs(l Layout, p RefsPage) {
147 @Shell(l, refsRail(p)) {
148 <div class="filter">
149 <label for="ref-filter" class="sr">filter</label>
150 <input
151 id="ref-filter"
152 type="search"
153 data-filter="#ref-rows > tr"
154 placeholder="Filter branches and tags…"
155 autocomplete="off"
156 hx-on:input="ermite.filter(this)"
157 />
158 </div>
159 <table class="list refs" aria-label="Branches and tags">
160 <thead>
161 <tr><th class="c-kind">kind</th><th class="c-name">name</th><th class="c-sha">commit</th><th class="c-sub">subject</th><th class="c-when">date</th></tr>
162 </thead>
163 @RefsBody(p)
164 </table>
165 if p.Note != "" {
166 <p class="note">{ p.Note }</p>
167 }
168 }
169}
170
171templ refRow(kind string, r RefRow) {
172 <tr data-cursor?={ r.Cursor }>
173 <td class="c-kind mute">{ kind }</td>
174 <td class="c-name">
175 <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
176 if r.Default {
177 <span class="pill">default</span>
178 }
179 </td>
180 <td class="c-sha">
181 if r.CHref != "" {
182 <a href={ templ.SafeURL(r.CHref) }>{ r.Short }</a>
183 } else {
184 { r.Short }
185 }
186 @CommitChecks(r.Checks)
187 </td>
188 <td class="c-sub">{ r.Subject }</td>
189 <td class="c-when dim">
190 if r.WhenAt != "" {
191 <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>
192 }
193 </td>
194 </tr>
195}
196
197templ refsRail(p RefsPage) {
198 <section aria-labelledby="r-refs">
199 <h2 id="r-refs">Overview</h2>
200 @RefsKV(p)
201 </section>
202}
203
204// State is a 404, 500 or empty-repository page inside the normal shell.
205templ State(l Layout, p StatePage) {
206 @Shell(l, nil) {
207 <section class="state" aria-labelledby="state-title">
208 <p class="eyebrow">{ p.Code }</p>
209 <h1 id="state-title">{ p.Title }</h1>
210 for _, line := range p.Lines {
211 <p class="dim">{ line }</p>
212 }
213 if p.Clone != nil {
214 @CloneBlock(*p.Clone)
215 }
216 <p class="state-back">
217 for _, b := range p.Back {
218 <a href={ templ.SafeURL(b.Href) }>{ b.Label }</a>
219 }
220 </p>
221 </section>
222 }
223}
224
225// Login signs the browser in with a login link. Opening the link consumes
226// nothing; only this form does. ermite.js submits it as soon as a browser
227// shows the page (data-autosubmit): link previews and prefetchers run no
228// script, so they still cannot use the link. Without scripts the owner
229// clicks the button.
230templ Login(l Layout, p LoginPage) {
231 @Shell(l, nil) {
232 <section class="state" aria-labelledby="state-title">
233 <h1 id="state-title">{ p.Title }</h1>
234 for _, line := range p.Lines {
235 <p class="dim">{ line }</p>
236 }
237 <form class="state-act" method="post" action={ templ.SafeURL(p.Action) } data-autosubmit>
238 <button type="submit" class="btn primary">{ p.Button }</button>
239 </form>
240 <p class="state-back"><a href="/">Back to home</a></p>
241 </section>
242 }
243}
244
245// PasskeyLogin is the sign-in page: a button the script (passkey.js) drives.
246// Without script the button is hidden and the page says what to do instead.
247templ PasskeyLogin(l Layout, p PasskeyLoginPage) {
248 @Shell(l, nil) {
249 <section class="state" aria-labelledby="state-title">
250 <h1 id="state-title">{ p.Title }</h1>
251 <form class="state-act" data-js data-passkey-form>
252 <button type="submit" class="btn primary" data-passkey="login" data-begin={ p.Begin } data-finish={ p.Finish }>{ p.Button }</button>
253 </form>
254 <p class="dim" role="status" aria-live="polite" data-passkey-status></p>
255 for _, line := range p.Lines {
256 <p class="dim">{ line }</p>
257 }
258 <p class="state-back"><a href="/">Back to home</a></p>
259 </section>
260 }
261}
262
263// Passkeys lists the owner's passkeys, removes them and adds one.
264templ Passkeys(l Layout, p PasskeysPage) {
265 @Shell(l, nil) {
266 <h2 class="rule-h"><span>Passkeys</span><span class="count">{ itoa(len(p.Rows)) }</span></h2>
267 <p class="dim">A passkey signs you in from this device or your phone, without a terminal. Removing one signs every browser out.</p>
268 if len(p.Rows) > 0 {
269 <table class="list passkeys" aria-label="Passkeys">
270 <thead>
271 <tr>
272 <th class="c-name">Name</th>
273 <th class="c-when">Created</th>
274 <th class="c-when">Last used</th>
275 <th class="c-sync">Synced</th>
276 <th class="c-act"><span class="sr">Remove</span></th>
277 </tr>
278 </thead>
279 <tbody>
280 for _, r := range p.Rows {
281 <tr>
282 <td class="c-name">{ r.Name }</td>
283 <td class="c-when nowrap">
284 <time
285 if r.CreatedAt != "" {
286 datetime={ r.CreatedAt }
287 }
288 >{ r.Created }</time>
289 </td>
290 <td class="c-when nowrap">
291 <time
292 if r.LastUsedAt != "" {
293 datetime={ r.LastUsedAt }
294 }
295 >{ r.LastUsed }</time>
296 </td>
297 <td class="c-sync">
298 if r.Synced {
299 yes
300 } else {
301 no
302 }
303 </td>
304 <td class="c-act">
305 <form method="post" action={ templ.SafeURL(r.Remove) }>
306 <button type="submit" class="btn danger" aria-label={ "Remove passkey " + r.Name }>Remove</button>
307 </form>
308 </td>
309 </tr>
310 }
311 </tbody>
312 </table>
313 } else {
314 <p class="lede dim">no passkeys yet.</p>
315 }
316 if p.Full {
317 <p class="dim">{ itoa(p.Max) } passkeys are registered, the most there can be. Remove one to add another.</p>
318 } else {
319 <form class="passkey-add" data-js data-passkey-form>
320 <label for="passkey-name" class="sr">Name of the new passkey</label>
321 <input id="passkey-name" type="text" placeholder="Name, for example Pixel 9" autocomplete="off" data-passkey-name/>
322 <button type="submit" class="btn primary" data-passkey="register" data-begin={ p.Begin } data-finish={ p.Finish }>Add a passkey</button>
323 </form>
324 }
325 <p class="dim" role="status" aria-live="polite" data-passkey-status></p>
326 }
327}