Implement Phase 8: Web UI with htmx templates
- HTML templates: layout, login, notebook list, notebook view, page viewer - Web server with chi router, embedded templates via //go:embed - Login/logout flow with session cookies - Notebook list, page grid with SVG thumbnails, page viewer - Share link views (same templates, no auth chrome) - Server command wired to start gRPC + REST + web servers concurrently - Graceful shutdown on SIGINT/SIGTERM Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
255
internal/webserver/handlers.go
Normal file
255
internal/webserver/handlers.go
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
package webserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/eng-pad-server/internal/auth"
|
||||||
|
"git.wntrmute.dev/kyle/eng-pad-server/internal/share"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ws *WebServer) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ws.render(w, "login.html", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *WebServer) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username := r.FormValue("username")
|
||||||
|
password := r.FormValue("password")
|
||||||
|
|
||||||
|
userID, err := auth.AuthenticateUser(ws.db, username, password)
|
||||||
|
if err != nil {
|
||||||
|
ws.render(w, "login.html", map[string]string{"Error": "Invalid credentials"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := auth.CreateToken(ws.db, userID, 24*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "session",
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
MaxAge: 86400,
|
||||||
|
})
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/notebooks", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *WebServer) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if cookie, err := r.Cookie("session"); err == nil {
|
||||||
|
_ = auth.DeleteToken(ws.db, cookie.Value)
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "session",
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
})
|
||||||
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *WebServer) handleNotebooks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := r.Context().Value(userIDKey).(int64)
|
||||||
|
|
||||||
|
rows, err := ws.db.QueryContext(r.Context(),
|
||||||
|
`SELECT n.id, n.title, n.page_size, n.synced_at,
|
||||||
|
(SELECT COUNT(*) FROM pages WHERE notebook_id = n.id)
|
||||||
|
FROM notebooks n WHERE n.user_id = ? ORDER BY n.synced_at DESC`, userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
type notebook struct {
|
||||||
|
ID int64
|
||||||
|
Title string
|
||||||
|
PageSize string
|
||||||
|
SyncedAt string
|
||||||
|
PageCount int
|
||||||
|
}
|
||||||
|
var notebooks []notebook
|
||||||
|
for rows.Next() {
|
||||||
|
var nb notebook
|
||||||
|
var syncedAt int64
|
||||||
|
if err := rows.Scan(&nb.ID, &nb.Title, &nb.PageSize, &syncedAt, &nb.PageCount); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nb.SyncedAt = time.UnixMilli(syncedAt).Format("2006-01-02 15:04")
|
||||||
|
notebooks = append(notebooks, nb)
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.render(w, "notebooks.html", map[string]any{"Notebooks": notebooks})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *WebServer) handleNotebook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
|
||||||
|
var title, pageSize string
|
||||||
|
err := ws.db.QueryRow("SELECT title, page_size FROM notebooks WHERE id = ?", id).Scan(&title, &pageSize)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := ws.db.Query("SELECT page_number FROM pages WHERE notebook_id = ? ORDER BY page_number", id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
type pageInfo struct {
|
||||||
|
Number int
|
||||||
|
SVGLink string
|
||||||
|
ViewLink string
|
||||||
|
}
|
||||||
|
var pages []pageInfo
|
||||||
|
for rows.Next() {
|
||||||
|
var num int
|
||||||
|
if err := rows.Scan(&num); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pages = append(pages, pageInfo{
|
||||||
|
Number: num,
|
||||||
|
SVGLink: fmt.Sprintf("/v1/notebooks/%d/pages/%d/svg", id, num),
|
||||||
|
ViewLink: fmt.Sprintf("/notebooks/%d/pages/%d", id, num),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.render(w, "notebook.html", map[string]any{
|
||||||
|
"Title": title,
|
||||||
|
"Pages": pages,
|
||||||
|
"PDFLink": fmt.Sprintf("/v1/notebooks/%d/pdf", id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *WebServer) handlePage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
num, _ := strconv.Atoi(chi.URLParam(r, "num"))
|
||||||
|
|
||||||
|
var title string
|
||||||
|
err := ws.db.QueryRow("SELECT title FROM notebooks WHERE id = ?", id).Scan(&title)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.render(w, "page.html", map[string]any{
|
||||||
|
"NotebookTitle": title,
|
||||||
|
"PageNumber": num,
|
||||||
|
"BackLink": fmt.Sprintf("/notebooks/%d", id),
|
||||||
|
"SVGLink": fmt.Sprintf("/v1/notebooks/%d/pages/%d/svg", id, num),
|
||||||
|
"JPGLink": fmt.Sprintf("/v1/notebooks/%d/pages/%d/jpg", id, num),
|
||||||
|
"PDFLink": fmt.Sprintf("/v1/notebooks/%d/pdf", id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *WebServer) handleShareNotebook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := chi.URLParam(r, "token")
|
||||||
|
notebookID, err := share.ValidateLink(ws.db, token)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Link not found or expired", http.StatusGone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var title string
|
||||||
|
_ = ws.db.QueryRow("SELECT title FROM notebooks WHERE id = ?", notebookID).Scan(&title)
|
||||||
|
|
||||||
|
rows, err := ws.db.Query("SELECT page_number FROM pages WHERE notebook_id = ? ORDER BY page_number", notebookID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
type pageInfo struct {
|
||||||
|
Number int
|
||||||
|
SVGLink string
|
||||||
|
ViewLink string
|
||||||
|
}
|
||||||
|
var pages []pageInfo
|
||||||
|
for rows.Next() {
|
||||||
|
var num int
|
||||||
|
if err := rows.Scan(&num); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pages = append(pages, pageInfo{
|
||||||
|
Number: num,
|
||||||
|
SVGLink: fmt.Sprintf("/s/%s/pages/%d/svg", token, num),
|
||||||
|
ViewLink: fmt.Sprintf("/s/%s/pages/%d", token, num),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.render(w, "notebook.html", map[string]any{
|
||||||
|
"Title": title,
|
||||||
|
"Pages": pages,
|
||||||
|
"PDFLink": fmt.Sprintf("/s/%s/pdf", token),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *WebServer) handleSharePage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := chi.URLParam(r, "token")
|
||||||
|
notebookID, err := share.ValidateLink(ws.db, token)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Link not found or expired", http.StatusGone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
num, _ := strconv.Atoi(chi.URLParam(r, "num"))
|
||||||
|
|
||||||
|
var title string
|
||||||
|
_ = ws.db.QueryRow("SELECT title FROM notebooks WHERE id = ?", notebookID).Scan(&title)
|
||||||
|
|
||||||
|
ws.render(w, "page.html", map[string]any{
|
||||||
|
"NotebookTitle": title,
|
||||||
|
"PageNumber": num,
|
||||||
|
"BackLink": fmt.Sprintf("/s/%s", token),
|
||||||
|
"SVGLink": fmt.Sprintf("/s/%s/pages/%d/svg", token, num),
|
||||||
|
"JPGLink": fmt.Sprintf("/s/%s/pages/%d/jpg", token, num),
|
||||||
|
"PDFLink": fmt.Sprintf("/s/%s/pdf", token),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- auth middleware ---
|
||||||
|
|
||||||
|
type ctxKey string
|
||||||
|
|
||||||
|
const userIDKey ctxKey = "user_id"
|
||||||
|
|
||||||
|
func (ws *WebServer) authMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie, err := r.Cookie("session")
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := auth.ValidateToken(ws.db, cookie.Value)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
ctx = context.WithValue(ctx, userIDKey, userID)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *WebServer) render(w http.ResponseWriter, name string, data any) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := ws.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||||
|
http.Error(w, "Template error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
78
internal/webserver/server.go
Normal file
78
internal/webserver/server.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package webserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/eng-pad-server/web"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Addr string
|
||||||
|
DB *sql.DB
|
||||||
|
BaseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebServer struct {
|
||||||
|
db *sql.DB
|
||||||
|
baseURL string
|
||||||
|
tmpl *template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
func Start(cfg Config) error {
|
||||||
|
templateFS, err := fs.Sub(web.Content, "templates")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("template fs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := template.ParseFS(templateFS, "*.html")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse templates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ws := &WebServer{
|
||||||
|
db: cfg.DB,
|
||||||
|
baseURL: cfg.BaseURL,
|
||||||
|
tmpl: tmpl,
|
||||||
|
}
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
// Static files
|
||||||
|
staticFS, _ := fs.Sub(web.Content, "static")
|
||||||
|
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||||
|
|
||||||
|
// Public routes
|
||||||
|
r.Get("/login", ws.handleLoginPage)
|
||||||
|
r.Post("/login", ws.handleLoginSubmit)
|
||||||
|
|
||||||
|
// Share routes (no auth)
|
||||||
|
r.Get("/s/{token}", ws.handleShareNotebook)
|
||||||
|
r.Get("/s/{token}/pages/{num}", ws.handleSharePage)
|
||||||
|
|
||||||
|
// Authenticated routes
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(ws.authMiddleware)
|
||||||
|
r.Get("/", http.RedirectHandler("/notebooks", http.StatusFound).ServeHTTP)
|
||||||
|
r.Get("/notebooks", ws.handleNotebooks)
|
||||||
|
r.Get("/notebooks/{id}", ws.handleNotebook)
|
||||||
|
r.Get("/notebooks/{id}/pages/{num}", ws.handlePage)
|
||||||
|
r.Get("/logout", ws.handleLogout)
|
||||||
|
})
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: cfg.Addr,
|
||||||
|
Handler: r,
|
||||||
|
ReadTimeout: 30 * time.Second,
|
||||||
|
WriteTimeout: 30 * time.Second,
|
||||||
|
IdleTimeout: 120 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Web UI listening on %s\n", cfg.Addr)
|
||||||
|
return srv.ListenAndServe()
|
||||||
|
}
|
||||||
6
web/embed.go
Normal file
6
web/embed.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed templates static
|
||||||
|
var Content embed.FS
|
||||||
3
web/static/htmx.min.js
vendored
Normal file
3
web/static/htmx.min.js
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// htmx placeholder — download from https://unpkg.com/htmx.org
|
||||||
|
// This is a minimal stub for initial development.
|
||||||
|
console.log("htmx stub loaded");
|
||||||
39
web/templates/layout.html
Normal file
39
web/templates/layout.html
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{block "title" .}}Engineering Pad{{end}}</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: system-ui, sans-serif; background: #fff; color: #111; }
|
||||||
|
.container { max-width: 1200px; margin: 0 auto; padding: 1rem; }
|
||||||
|
nav { background: #111; color: #fff; padding: 0.75rem 1rem; display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
nav a { color: #fff; text-decoration: none; }
|
||||||
|
nav a:hover { text-decoration: underline; }
|
||||||
|
h1 { font-size: 1.5rem; margin-bottom: 1rem; }
|
||||||
|
.card { border: 1px solid #ccc; border-radius: 4px; padding: 1rem; margin-bottom: 0.5rem; }
|
||||||
|
.card:hover { background: #f5f5f5; }
|
||||||
|
a.card-link { text-decoration: none; color: inherit; display: block; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; }
|
||||||
|
.page-thumb { border: 1px solid #ccc; background: #fff; aspect-ratio: 0.773; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.page-thumb img { width: 100%; height: 100%; object-fit: contain; }
|
||||||
|
.btn { display: inline-block; padding: 0.5rem 1rem; border: 1px solid #111; border-radius: 4px; text-decoration: none; color: #111; background: #fff; cursor: pointer; }
|
||||||
|
.btn:hover { background: #f0f0f0; }
|
||||||
|
.actions { display: flex; gap: 0.5rem; margin: 1rem 0; }
|
||||||
|
input[type="text"], input[type="password"] { padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; width: 100%; max-width: 300px; }
|
||||||
|
label { display: block; margin-bottom: 0.25rem; font-weight: bold; }
|
||||||
|
.form-group { margin-bottom: 1rem; }
|
||||||
|
</style>
|
||||||
|
<script src="/static/htmx.min.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<a href="/notebooks">Engineering Pad</a>
|
||||||
|
{{block "nav-right" .}}{{end}}
|
||||||
|
</nav>
|
||||||
|
<div class="container">
|
||||||
|
{{block "content" .}}{{end}}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
16
web/templates/login.html
Normal file
16
web/templates/login.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{{define "title"}}Login — Engineering Pad{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>Login</h1>
|
||||||
|
<form method="POST" action="/login">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" id="username" name="username" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">Login</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
20
web/templates/notebook.html
Normal file
20
web/templates/notebook.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{{define "title"}}{{.Title}} — Engineering Pad{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>{{.Title}}</h1>
|
||||||
|
<div class="actions">
|
||||||
|
<a href="{{.PDFLink}}" class="btn">Download PDF</a>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
{{range .Pages}}
|
||||||
|
<div>
|
||||||
|
<a href="{{.ViewLink}}">
|
||||||
|
<div class="page-thumb">
|
||||||
|
<img src="{{.SVGLink}}" alt="Page {{.Number}}">
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div style="text-align:center; margin-top:0.25rem;">Page {{.Number}}</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
17
web/templates/notebooks.html
Normal file
17
web/templates/notebooks.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{{define "title"}}Notebooks — Engineering Pad{{end}}
|
||||||
|
|
||||||
|
{{define "nav-right"}}<a href="/logout">Logout</a>{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>Notebooks</h1>
|
||||||
|
{{range .Notebooks}}
|
||||||
|
<a class="card-link" href="/notebooks/{{.ID}}">
|
||||||
|
<div class="card">
|
||||||
|
<strong>{{.Title}}</strong>
|
||||||
|
<br><small>{{.PageSize}} — {{.PageCount}} pages — synced {{.SyncedAt}}</small>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{{else}}
|
||||||
|
<p>No notebooks synced yet.</p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
13
web/templates/page.html
Normal file
13
web/templates/page.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{{define "title"}}Page {{.PageNumber}} — {{.NotebookTitle}} — Engineering Pad{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>{{.NotebookTitle}} — Page {{.PageNumber}}</h1>
|
||||||
|
<div class="actions">
|
||||||
|
<a href="{{.BackLink}}" class="btn">Back to notebook</a>
|
||||||
|
<a href="{{.JPGLink}}" class="btn">Download JPG</a>
|
||||||
|
<a href="{{.PDFLink}}" class="btn">Download PDF</a>
|
||||||
|
</div>
|
||||||
|
<div style="border: 1px solid #ccc; background: #fff;">
|
||||||
|
<img src="{{.SVGLink}}" alt="Page {{.PageNumber}}" style="width: 100%; height: auto;">
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user