+2
-2
.air/appview.toml
+2
-2
.air/appview.toml
···
1
1
[build]
2
-
cmd = "go build -o .bin/app ./cmd/appview/main.go"
2
+
cmd = "tailwindcss -i input.css -o ./appview/pages/static/tw.css && go build -o .bin/app ./cmd/appview/main.go"
3
3
bin = ".bin/app"
4
4
root = "."
5
5
6
6
exclude_regex = [".*_templ.go"]
7
-
include_ext = ["go", "templ", "html"]
7
+
include_ext = ["go", "templ", "html", "css"]
8
8
exclude_dir = ["target", "atrium"]
+28
appview/pages/htmx.go
+28
appview/pages/htmx.go
···
1
+
package pages
2
+
3
+
import (
4
+
"fmt"
5
+
"net/http"
6
+
)
7
+
8
+
// Notice performs a hx-oob-swap to replace the content of an element with a message.
9
+
// Pass the id of the element and the message to display.
10
+
func (s *Pages) Notice(w http.ResponseWriter, id, msg string) {
11
+
html := fmt.Sprintf(`<span id="%s" hx-swap-oob="innerHTML">%s</span>`, id, msg)
12
+
13
+
w.Header().Set("Content-Type", "text/html")
14
+
w.WriteHeader(http.StatusOK)
15
+
w.Write([]byte(html))
16
+
}
17
+
18
+
// HxRedirect is a full page reload with a new location.
19
+
func (s *Pages) HxRedirect(w http.ResponseWriter, location string) {
20
+
w.Header().Set("HX-Redirect", location)
21
+
w.WriteHeader(http.StatusOK)
22
+
}
23
+
24
+
// HxLocation is an SPA-style navigation to a new location.
25
+
func (s *Pages) HxLocation(w http.ResponseWriter, location string) {
26
+
w.Header().Set("HX-Location", location)
27
+
w.WriteHeader(http.StatusOK)
28
+
}
+3
-1
appview/pages/templates/layouts/base.html
+3
-1
appview/pages/templates/layouts/base.html
···
7
7
name="viewport"
8
8
content="width=device-width, initial-scale=1.0"
9
9
/>
10
+
<script src="/static/htmx.min.js"></script>
11
+
<link href="/static/tw.css" rel="stylesheet" />
10
12
<title>{{ block "title" . }}tangled{{ end }}</title>
11
13
</head>
12
-
<body>
14
+
<body class="container">
13
15
<header class="topbar">
14
16
{{ block "topbar" . }}
15
17
{{ template "layouts/topbar" . }}
+41
-20
appview/pages/templates/repo/new.html
+41
-20
appview/pages/templates/repo/new.html
···
1
-
{{define "title"}}new repo{{end}}
1
+
{{ define "title" }}new repo{{ end }}
2
2
3
-
{{define "content"}}
4
-
<h1>new repo</h1>
5
-
<form method="POST" action="/repo/new">
6
-
<label for="name">repo name</label>
7
-
<input type="text" id="name" name="name" required />
3
+
{{ define "content" }}
4
+
<div class="container">
5
+
<h1>new repo</h1>
6
+
<form>
7
+
<label for="name">repo name</label>
8
+
<input
9
+
type="text"
10
+
id="name"
11
+
name="name"
12
+
class="px-1 border-2 border-blue-100"
13
+
required
14
+
/>
8
15
9
-
<br>
16
+
<fieldset class="border-blue-100 border-2">
17
+
<legend>select a knot:</legend>
18
+
{{ range .Knots }}
19
+
<label>
20
+
<input
21
+
class="px-1 border-2 border-blue-500"
22
+
type="radio"
23
+
name="domain"
24
+
value="{{ . }}"
25
+
/>
26
+
{{ . }} </label
27
+
><br />
28
+
{{ else }}
29
+
<p>no knots available</p>
30
+
{{ end }}
31
+
</fieldset>
10
32
11
-
<fieldset>
12
-
<legend>select a knot:</legend>
13
-
{{ range .Knots }}
14
-
<label>
15
-
<input type="radio" name="domain" value="{{ . }}"> {{ . }}
16
-
</label><br>
17
-
<button type="submit">create repo</button>
18
-
{{ else }}
19
-
<p>no knots available</p>
20
-
{{ end }}
21
-
</fieldset>
33
+
<button
34
+
type="submit"
35
+
hx-post="/repo/new"
36
+
hx-swap="none"
37
+
class="my-2 btn"
38
+
>
39
+
create repo
40
+
</button>
41
+
</form>
42
+
</div>
22
43
23
-
</form>
24
-
{{end}}
44
+
<div id="repo" class="error"></div>
45
+
{{ end }}
+13
-11
appview/state/state.go
+13
-11
appview/state/state.go
···
431
431
knots, err := s.enforcer.GetDomainsForUser(user.Did)
432
432
433
433
if err != nil {
434
-
log.Println("invalid user?", err)
434
+
s.pages.Notice(w, "repo", "Invalid user account.")
435
435
return
436
436
}
437
437
···
444
444
445
445
domain := r.FormValue("domain")
446
446
if domain == "" {
447
-
log.Println("invalid form")
447
+
s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
448
448
return
449
449
}
450
450
451
451
repoName := r.FormValue("name")
452
452
if repoName == "" {
453
-
log.Println("invalid form")
453
+
s.pages.Notice(w, "repo", "Invalid repo name.")
454
454
return
455
455
}
456
456
457
457
ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create")
458
458
if err != nil || !ok {
459
-
w.Write([]byte("domain inaccessible to you"))
459
+
s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
460
460
return
461
461
}
462
462
463
463
secret, err := s.db.GetRegistrationKey(domain)
464
464
if err != nil {
465
-
log.Printf("no key found for domain %s: %s\n", domain, err)
465
+
s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain))
466
466
return
467
467
}
468
468
469
469
client, err := NewSignedClient(domain, secret)
470
470
if err != nil {
471
-
log.Println("failed to create client to ", domain)
471
+
s.pages.Notice(w, "repo", "Failed to connect to knot server.")
472
+
return
472
473
}
473
474
474
475
resp, err := client.NewRepo(user.Did, repoName)
475
476
if err != nil {
476
-
log.Println("failed to send create repo request", err)
477
+
s.pages.Notice(w, "repo", "Failed to create repository on knot server.")
477
478
return
478
479
}
479
480
if resp.StatusCode != http.StatusNoContent {
480
-
log.Println("server returned ", resp.StatusCode)
481
+
s.pages.Notice(w, "repo", fmt.Sprintf("Server returned unexpected status: %d", resp.StatusCode))
481
482
return
482
483
}
483
484
···
489
490
}
490
491
err = s.db.AddRepo(repo)
491
492
if err != nil {
492
-
log.Println("failed to add repo to db", err)
493
+
s.pages.Notice(w, "repo", "Failed to save repository information.")
493
494
return
494
495
}
495
496
496
497
// acls
497
498
err = s.enforcer.AddRepo(user.Did, domain, filepath.Join(user.Did, repoName))
498
499
if err != nil {
499
-
log.Println("failed to set up acls", err)
500
+
s.pages.Notice(w, "repo", "Failed to set up repository permissions.")
500
501
return
501
502
}
502
503
503
-
w.Write([]byte("created!"))
504
+
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
505
+
return
504
506
}
505
507
}
506
508
+28
input.css
+28
input.css
···
12
12
@apply text-2xl;
13
13
@apply font-sans;
14
14
@apply text-gray-900;
15
+
@apply py-4;
15
16
}
17
+
16
18
::selection {
17
19
@apply bg-green-400;
18
20
@apply text-gray-900;
19
21
@apply bg-opacity-30;
22
+
}
23
+
a {
24
+
@apply underline text-blue-600 hover:text-blue-800 visited:text-purple-600;
25
+
}
26
+
27
+
@layer components {
28
+
.btn {
29
+
@apply relative z-10 inline-flex min-h-[36px] cursor-pointer items-center
30
+
justify-center border-0 bg-transparent px-3 pb-[0.3rem] text-base
31
+
text-gray-800 before:absolute before:inset-0 before:-z-10
32
+
before:block before:rounded before:border before:border-cyan-200
33
+
before:bg-white before:shadow-[0_4px_3px_0_rgba(20,20,96,0.1),inset_0_-5px_0_0_#ebebf6]
34
+
before:content-[''] hover:before:border-cyan-600
35
+
hover:before:bg-cyan-600
36
+
hover:before:shadow-[0_4px_3px_0_rgba(20,20,96,0.1),inset_0_-5px_0_0_#c2b3ff]
37
+
focus:outline-none focus-visible:before:outline
38
+
focus-visible:before:outline-4 focus-visible:before:outline-[#fc440f]
39
+
active:border-t-4 active:border-transparent active:py-1
40
+
active:before:shadow-none;
41
+
}
42
+
}
43
+
44
+
@layer utilities {
45
+
.error {
46
+
@apply py-1 border-red-400 text-red-600;
47
+
}
20
48
}
21
49
}