Skip to content

Commit f77739b

Browse files
paskalumputun
authored andcommitted
Switch the main page to HTMX
1 parent ec137d0 commit f77739b

File tree

19 files changed

+229
-175
lines changed

19 files changed

+229
-175
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
- name: set up go
1515
uses: actions/setup-go@v5
1616
with:
17-
go-version: "1.21"
17+
go-version: "1.22"
1818
id: go
1919

2020
- name: launch mongodb

backend/.golangci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ run:
44
linters:
55
enable:
66
- bodyclose
7+
- copyloopvar
78
- dogsled
89
- dupl
910
- errcheck
10-
- exportloopref
1111
- gochecknoinits
1212
- goconst
1313
- gocritic

backend/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ var revision string
2020
var opts struct {
2121
Address string `long:"address" env:"UKEEPER_ADDRESS" default:"" description:"listening address"`
2222
Port int `long:"port" env:"UKEEPER_PORT" default:"8080" description:"port"`
23-
FrontendDir string `long:"frontend_dir" env:"FRONTEND_DIR" default:"/srv/web" description:"directory with frontend files"`
23+
FrontendDir string `long:"frontend_dir" env:"FRONTEND_DIR" default:"/srv/web" description:"directory with frontend templates and static/ directory for static assets"`
2424
Credentials map[string]string `long:"creds" env:"CREDS" description:"credentials for protected calls (POST, DELETE /rules)"`
2525
Token string `long:"token" env:"UKEEPER_TOKEN" description:"token for /content/v1/parser endpoint auth"`
2626
MongoURI string `short:"m" long:"mongo_uri" env:"MONGO_URI" required:"true" description:"MongoDB connection string"`

backend/main_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ func Test_Main(t *testing.T) {
2424
os.Args = []string{"test", "--port=" + strconv.Itoa(port), "--dbg",
2525
"--mongo_uri=mongodb://localhost:27017/",
2626
"--mongo-db=test_ureadability",
27-
"--frontend_dir=.",
27+
"--frontend_dir=web",
2828
}
2929

3030
done := make(chan struct{})

backend/rest/server.go

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import (
55
"context"
66
"crypto/subtle"
77
"fmt"
8+
"html/template"
89
"net/http"
10+
"os"
11+
"path/filepath"
912
"time"
1013

1114
"github.com/didip/tollbooth/v7"
@@ -28,6 +31,8 @@ type Server struct {
2831
Version string
2932
Token string
3033
Credentials map[string]string
34+
35+
indexPage *template.Template
3136
}
3237

3338
// JSON is a map alias, just for convenience
@@ -37,6 +42,9 @@ type JSON map[string]interface{}
3742
func (s *Server) Run(ctx context.Context, address string, port int, frontendDir string) {
3843
log.Printf("[INFO] activate rest server on %s:%d", address, port)
3944

45+
_ = os.Mkdir(filepath.Join(frontendDir, "components"), 0o700)
46+
t := template.Must(template.ParseGlob(filepath.Join(frontendDir, "components", "*.gohtml")))
47+
s.indexPage = template.Must(template.Must(t.Clone()).ParseFiles(filepath.Join(frontendDir, "index.gohtml")))
4048
httpServer := &http.Server{
4149
Addr: fmt.Sprintf("%s:%d", address, port),
4250
Handler: s.routes(frontendDir),
@@ -78,20 +86,39 @@ func (s *Server) routes(frontendDir string) chi.Router {
7886
r.Group(func(protected chi.Router) {
7987
protected.Use(basicAuth("ureadability", s.Credentials))
8088
protected.Post("/rule", s.saveRule)
81-
protected.Delete("/rule/{id}", s.deleteRule)
89+
protected.Post("/toggle-rule/{id}", s.toggleRule)
8290
})
8391
})
8492

85-
fs, err := UM.NewFileServer("/", frontendDir, UM.FsOptSPA)
93+
router.Get("/", s.handleIndex)
94+
95+
_ = os.Mkdir(filepath.Join(frontendDir, "static"), 0o700)
96+
fs, err := UM.NewFileServer("/", filepath.Join(frontendDir, "static"), UM.FsOptSPA)
8697
if err != nil {
87-
log.Fatalf("unable to create, %v", err)
98+
log.Fatalf("unable to create file server, %v", err)
8899
}
89100
router.Get("/*", func(w http.ResponseWriter, r *http.Request) {
90101
fs.ServeHTTP(w, r)
91102
})
92103
return router
93104
}
94105

106+
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
107+
rules := s.Readability.Rules.All(r.Context())
108+
data := struct {
109+
Title string
110+
Rules []datastore.Rule
111+
}{
112+
Title: "Правила",
113+
Rules: rules,
114+
}
115+
err := s.indexPage.ExecuteTemplate(w, "base.gohtml", data)
116+
if err != nil {
117+
log.Printf("[WARN] failed to render index template, %v", err)
118+
http.Error(w, err.Error(), http.StatusInternalServerError)
119+
}
120+
}
121+
95122
func (s *Server) extractArticle(w http.ResponseWriter, r *http.Request) {
96123
artRequest := extractor.Response{}
97124
if err := render.DecodeJSON(r.Body, &artRequest); err != nil {
@@ -132,14 +159,14 @@ func (s *Server) extractArticleEmulateReadability(w http.ResponseWriter, r *http
132159
return
133160
}
134161

135-
url := r.URL.Query().Get("url")
136-
if url == "" {
162+
extractURL := r.URL.Query().Get("url")
163+
if extractURL == "" {
137164
render.Status(r, http.StatusExpectationFailed)
138165
render.JSON(w, r, JSON{"error": "no url passed"})
139166
return
140167
}
141168

142-
res, err := s.Readability.Extract(r.Context(), url)
169+
res, err := s.Readability.Extract(r.Context(), extractURL)
143170
if err != nil {
144171
render.Status(r, http.StatusBadRequest)
145172
render.JSON(w, r, JSON{"error": err.Error()})
@@ -207,16 +234,33 @@ func (s *Server) saveRule(w http.ResponseWriter, r *http.Request) {
207234
render.JSON(w, r, &srule)
208235
}
209236

210-
// deleteRule marks rule as disabled
211-
func (s *Server) deleteRule(w http.ResponseWriter, r *http.Request) {
237+
func (s *Server) toggleRule(w http.ResponseWriter, r *http.Request) {
212238
id := getBid(chi.URLParam(r, "id"))
213-
err := s.Readability.Rules.Disable(r.Context(), id)
239+
rule, found := s.Readability.Rules.GetByID(r.Context(), id)
240+
if !found {
241+
log.Printf("[WARN] rule not found for id: %s", id.Hex())
242+
http.Error(w, "Rule not found", http.StatusNotFound)
243+
return
244+
}
245+
246+
rule.Enabled = !rule.Enabled
247+
var err error
248+
if rule.Enabled {
249+
_, err = s.Readability.Rules.Save(r.Context(), rule)
250+
} else {
251+
err = s.Readability.Rules.Disable(r.Context(), id)
252+
}
253+
214254
if err != nil {
215-
render.Status(r, http.StatusBadRequest)
216-
render.JSON(w, r, JSON{"error": err.Error()})
255+
log.Printf("[ERROR] failed to toggle rule: %v", err)
256+
http.Error(w, err.Error(), http.StatusInternalServerError)
217257
return
218258
}
219-
render.JSON(w, r, JSON{"disabled": id})
259+
260+
err = s.indexPage.ExecuteTemplate(w, "rule-row", rule)
261+
if err != nil {
262+
http.Error(w, err.Error(), http.StatusInternalServerError)
263+
}
220264
}
221265

222266
// authFake just a dummy post request used for external check for protected resource

backend/rest/server_test.go

Lines changed: 97 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"html/template"
78
"io"
89
"math/rand/v2"
910
"net/http"
1011
"net/http/httptest"
1112
"os"
13+
"path/filepath"
1214
"strings"
1315
"testing"
1416
"time"
@@ -29,7 +31,7 @@ func TestServer_FileServer(t *testing.T) {
2931
}
3032
testHTMLName := "test-ureadability.html"
3133
dir := os.TempDir()
32-
testHTMLFile := dir + "/" + testHTMLName
34+
testHTMLFile := filepath.Join(dir, testHTMLName)
3335
err := os.WriteFile(testHTMLFile, []byte("some html"), 0o700)
3436
require.NoError(t, err)
3537

@@ -40,10 +42,21 @@ func TestServer_FileServer(t *testing.T) {
4042
ts := httptest.NewServer(srv.routes(dir))
4143
defer ts.Close()
4244

45+
// no file served because it's outside of static dir
4346
body, code := get(t, ts.URL+"/"+testHTMLName)
47+
assert.Equal(t, http.StatusNotFound, code)
48+
assert.Contains(t, body, "404 page not found")
49+
ts.Close()
50+
51+
_ = os.Mkdir(filepath.Join(dir, "static"), 0o700)
52+
require.NoError(t, os.Rename(testHTMLFile, filepath.Join(dir, "static", testHTMLName)))
53+
54+
ts = httptest.NewServer(srv.routes(dir))
55+
body, code = get(t, ts.URL+"/"+testHTMLName)
4456
assert.Equal(t, http.StatusOK, code)
4557
assert.Equal(t, "some html", body)
46-
_ = os.Remove(testHTMLFile)
58+
require.NoError(t, os.Remove(filepath.Join(dir, "static", testHTMLName)))
59+
require.NoError(t, os.Remove(filepath.Join(dir, "static")))
4760
}
4861

4962
func TestServer_Shutdown(t *testing.T) {
@@ -60,7 +73,7 @@ func TestServer_Shutdown(t *testing.T) {
6073
}()
6174

6275
st := time.Now()
63-
srv.Run(ctx, "127.0.0.1", 0, ".")
76+
srv.Run(ctx, "127.0.0.1", 0, "../web")
6477
assert.True(t, time.Since(st).Seconds() < 1, "should take about 100ms")
6578
<-done
6679
}
@@ -263,7 +276,7 @@ func TestServer_RuleHappyFlow(t *testing.T) {
263276
assert.NotEmpty(t, b)
264277

265278
// disable the rule
266-
r, err = del(t, ts.URL+"/api/rule/"+fmt.Sprintf(`%s`, rule.ID.Hex()))
279+
r, err = post(t, ts.URL+"/api/toggle-rule/"+rule.ID.Hex(), "")
267280
assert.NoError(t, err)
268281
// read body for error message
269282
body, err := io.ReadAll(r.Body)
@@ -357,6 +370,81 @@ func TestServer_FakeAuth(t *testing.T) {
357370
assert.Equal(t, "{\"error\":\"not found\"}\n", b)
358371
}
359372

373+
func TestServer_HandleIndex(t *testing.T) {
374+
ts, _ := startupT(t)
375+
defer ts.Close()
376+
randomDomainName := randStringBytesRmndr(42) + ".com"
377+
378+
// Add a test rule
379+
_, err := post(t, ts.URL+"/api/rule", fmt.Sprintf(`{"domain": "%s", "content": "test content"}`, randomDomainName))
380+
require.NoError(t, err)
381+
382+
// Test index page
383+
resp, err := http.Get(ts.URL + "/")
384+
require.NoError(t, err)
385+
defer resp.Body.Close()
386+
387+
body, err := io.ReadAll(resp.Body)
388+
require.NoError(t, err)
389+
390+
assert.Equal(t, http.StatusOK, resp.StatusCode)
391+
assert.Contains(t, string(body), randomDomainName)
392+
assert.Contains(t, string(body), "test content")
393+
assert.Contains(t, string(body), "Правила")
394+
}
395+
396+
func TestServer_ToggleRule(t *testing.T) {
397+
ts, _ := startupT(t)
398+
defer ts.Close()
399+
randomDomainName := randStringBytesRmndr(42) + ".com"
400+
401+
// Add a test rule
402+
r, err := post(t, ts.URL+"/api/rule", fmt.Sprintf(`{"domain": "%s", "content": "test content"}`, randomDomainName))
403+
require.NoError(t, err)
404+
var rule datastore.Rule
405+
err = json.NewDecoder(r.Body).Decode(&rule)
406+
require.NoError(t, err)
407+
assert.Equal(t, randomDomainName, rule.Domain)
408+
assert.Equal(t, "test content", rule.Content)
409+
assert.True(t, rule.Enabled)
410+
411+
// Toggle rule (disable)
412+
r, err = post(t, ts.URL+"/api/toggle-rule/"+rule.ID.Hex(), "")
413+
require.NoError(t, err)
414+
assert.Equal(t, http.StatusOK, r.StatusCode)
415+
416+
body, err := io.ReadAll(r.Body)
417+
require.NoError(t, err)
418+
assert.NoError(t, r.Body.Close())
419+
assert.Contains(t, string(body), `class="rules__row rules__row_disabled"`, string(body))
420+
421+
// Toggle rule again (enable)
422+
r, err = post(t, ts.URL+"/api/toggle-rule/"+rule.ID.Hex(), "")
423+
require.NoError(t, err)
424+
425+
assert.Equal(t, http.StatusOK, r.StatusCode)
426+
427+
body, err = io.ReadAll(r.Body)
428+
require.NoError(t, err)
429+
assert.NoError(t, r.Body.Close())
430+
assert.NotContains(t, string(body), `class="rules__row rules__row_disabled"`)
431+
}
432+
433+
func TestServer_ToggleRuleNotFound(t *testing.T) {
434+
ts, _ := startupT(t)
435+
defer ts.Close()
436+
437+
// Toggle rule (disable)
438+
r, err := post(t, ts.URL+"/api/toggle-rule/non-existing", "")
439+
require.NoError(t, err)
440+
assert.Equal(t, http.StatusNotFound, r.StatusCode)
441+
442+
body, err := io.ReadAll(r.Body)
443+
require.NoError(t, err)
444+
assert.NoError(t, r.Body.Close())
445+
assert.Contains(t, string(body), `Rule not found`, string(body))
446+
}
447+
360448
func get(t *testing.T, url string) (response string, statusCode int) {
361449
r, err := http.Get(url)
362450
assert.NoError(t, err)
@@ -396,7 +484,11 @@ func startupT(t *testing.T) (*httptest.Server, *Server) {
396484
Version: "dev-test",
397485
}
398486

399-
return httptest.NewServer(srv.routes(".")), &srv
487+
webDir := "../web"
488+
templates := template.Must(template.ParseGlob(filepath.Join(webDir, "components", "*.gohtml")))
489+
srv.indexPage = template.Must(template.Must(templates.Clone()).ParseFiles(filepath.Join(webDir, "index.gohtml")))
490+
491+
return httptest.NewServer(srv.routes(webDir)), &srv
400492
}
401493

402494
// thanks to https://stackoverflow.com/a/31832326/961092

backend/web/components/base.gohtml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<!DOCTYPE html>
2+
<html lang="ru">
3+
{{template "head" .}}
4+
<body class="page">
5+
<div class="header wrapper page__header">
6+
<a href="/" class="header__title link">uReadability</a>
7+
</div>
8+
9+
<div class="wrapper">
10+
{{block "content" .}}{{end}}
11+
</div>
12+
13+
{{template "footer"}}
14+
15+
</body>
16+
</html>

backend/web/components/footer.gohtml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{{define "footer"}}
2+
<div class="footer wrapper page__footer">
3+
uReadability,
4+
<script>document.write((new Date()).getFullYear().toString());</script>
5+
</div>
6+
{{end}}

backend/web/components/head.gohtml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{{define "head"}}
2+
<head>
3+
<meta charset="UTF-8">
4+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
5+
<title>{{.Title}} — uReadability</title>
6+
<link rel="shortcut icon" type="image/png" href="/favicon.png">
7+
<link rel="stylesheet" href="/main.css">
8+
<script src="/htmx.js"></script>
9+
</head>
10+
{{end}}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{{define "rule-row"}}
2+
<tr class="rules__row {{if not .Enabled}}rules__row_disabled{{end}}" data-id="{{.ID.Hex}}">
3+
<td class="rules__domain-cell">
4+
<a href="/edit?id={{.ID.Hex}}" class="link">{{if .Domain}}{{.Domain}}{{else}}unspecified{{end}}</a>
5+
</td>
6+
<td class="rules__content-cell">{{.Content}}</td>
7+
<td class="rules__enabled-cell">
8+
<input class="rules__enabled" type="checkbox" {{if .Enabled}}checked{{end}}
9+
hx-post="/api/toggle-rule/{{.ID.Hex}}"
10+
hx-swap="outerHTML"
11+
hx-target="closest tr">
12+
</td>
13+
</tr>
14+
{{end}}

0 commit comments

Comments
 (0)