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; +}