From 25bc305fc9811d1471d02e7e5d63f2285289d315 Mon Sep 17 00:00:00 2001 From: agent-company Date: Sat, 28 Mar 2026 18:06:10 +0000 Subject: [PATCH] feat: add mobile-friendly HTTP 404 and 500 error pages Add ErrorNotFound and ErrorInternal handler methods that render styled error pages using the error.html template, with proper status codes, responsive layout, SVG icons, and HTMX fragment support. Replace the plain-text http.NotFound call in Dashboard with the new styled handler. Closes leeworks-agents/gitea-mobile#131 Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/handlers/handlers.go | 49 +++++++++++++++++- internal/handlers/handlers_test.go | 81 ++++++++++++++++++++++++++++++ internal/templates/error.html | 23 +++++++++ static/style.css | 53 +++++++++++++++++++ 4 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 internal/templates/error.html diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 4e9ed34..6067a12 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -181,11 +181,58 @@ func renderPage(w http.ResponseWriter, r *http.Request, title, activeTab string, } } +// errorData holds the template data for error pages. +type errorData struct { + Code int + Title string + Message string +} + +// ErrorNotFound renders a mobile-friendly 404 error page. +func (h *Handler) ErrorNotFound(w http.ResponseWriter, r *http.Request) { + data := errorData{ + Code: http.StatusNotFound, + Title: "Page Not Found", + Message: "The page you are looking for does not exist or has been moved.", + } + h.renderError(w, r, data) +} + +// ErrorInternal renders a mobile-friendly 500 error page. +func (h *Handler) ErrorInternal(w http.ResponseWriter, r *http.Request) { + data := errorData{ + Code: http.StatusInternalServerError, + Title: "Internal Server Error", + Message: "Something went wrong on our end. Please try again later.", + } + h.renderError(w, r, data) +} + +// renderError renders the error template with the given data and status code. +func (h *Handler) renderError(w http.ResponseWriter, r *http.Request, data errorData) { + tmpl, err := template.ParseFiles("internal/templates/error.html") + if err != nil { + slog.Error("failed to parse error template", "error", err) + http.Error(w, fmt.Sprintf("%d %s", data.Code, data.Title), data.Code) + return + } + + var buf strings.Builder + if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil { + slog.Error("failed to execute error template", "error", err) + http.Error(w, fmt.Sprintf("%d %s", data.Code, data.Title), data.Code) + return + } + + w.WriteHeader(data.Code) + renderPage(w, r, data.Title, "", buf.String()) +} + // Dashboard handles GET / — the triage queue. func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { // Only handle exact root path. if r.URL.Path != "/" { - http.NotFound(w, r) + h.ErrorNotFound(w, r) return } diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index 659549c..2fbcccb 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -183,6 +183,87 @@ func TestAddComment_EmptyBody(t *testing.T) { } } +func TestErrorNotFound(t *testing.T) { + h := newTestHandler() + req := httptest.NewRequest(http.MethodGet, "/nonexistent", nil) + w := httptest.NewRecorder() + + h.ErrorNotFound(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound) + } + body := w.Body.String() + if body == "" { + t.Error("expected non-empty response body") + } + if !contains(body, "404") { + t.Error("expected body to contain '404'") + } + if !contains(body, "Page Not Found") { + t.Error("expected body to contain 'Page Not Found'") + } +} + +func TestErrorInternal(t *testing.T) { + h := newTestHandler() + req := httptest.NewRequest(http.MethodGet, "/error", nil) + w := httptest.NewRecorder() + + h.ErrorInternal(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("status = %d, want %d", w.Code, http.StatusInternalServerError) + } + body := w.Body.String() + if body == "" { + t.Error("expected non-empty response body") + } + if !contains(body, "500") { + t.Error("expected body to contain '500'") + } + if !contains(body, "Internal Server Error") { + t.Error("expected body to contain 'Internal Server Error'") + } +} + +func TestDashboard_NonRootPath_Returns404(t *testing.T) { + h := newTestHandler() + req := httptest.NewRequest(http.MethodGet, "/unknown/path", nil) + w := httptest.NewRecorder() + + h.Dashboard(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound) + } + body := w.Body.String() + if !contains(body, "404") { + t.Error("expected body to contain '404' for non-root path") + } +} + +func TestErrorNotFound_HTMX(t *testing.T) { + h := newTestHandler() + req := httptest.NewRequest(http.MethodGet, "/nonexistent", nil) + req.Header.Set("HX-Request", "true") + w := httptest.NewRecorder() + + h.ErrorNotFound(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound) + } + body := w.Body.String() + // HTMX response should not contain DOCTYPE. + if contains(body, "= len(substr) && searchString(s, substr) } diff --git a/internal/templates/error.html b/internal/templates/error.html new file mode 100644 index 0000000..9108de0 --- /dev/null +++ b/internal/templates/error.html @@ -0,0 +1,23 @@ +{{define "content"}} +
+
+ {{if eq .Code 404}} + + + + + + {{else}} + + + + + + {{end}} +
+

{{.Code}}

+

{{.Title}}

+

{{.Message}}

+ Go to Dashboard +
+{{end}} diff --git a/static/style.css b/static/style.css index b323b14..3ea70a4 100644 --- a/static/style.css +++ b/static/style.css @@ -545,3 +545,56 @@ a:active { --text-link: #0969da; } } + +/* Error page */ +.error-page { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; + text-align: center; + padding: var(--spacing-lg); +} + +.error-icon { + color: var(--text-secondary); + margin-bottom: var(--spacing-lg); +} + +.error-code { + font-size: 4rem; + font-weight: 700; + color: var(--text-primary); + line-height: 1; + margin-bottom: var(--spacing-sm); +} + +.error-title { + font-size: var(--font-xl); + color: var(--text-primary); + margin-bottom: var(--spacing-sm); +} + +.error-message { + font-size: var(--font-base); + color: var(--text-secondary); + margin-bottom: var(--spacing-lg); + max-width: 300px; +} + +.error-home-link { + display: inline-block; + padding: var(--spacing-sm) var(--spacing-lg); + background: var(--accent-blue); + color: #fff; + border-radius: var(--radius); + text-decoration: none; + font-size: var(--font-base); + font-weight: 500; + transition: opacity 0.15s; +} + +.error-home-link:active { + opacity: 0.8; +} -- 2.52.0