feat: add HTTP 404 and 500 error pages with mobile-friendly styling #144
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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, "<!DOCTYPE") {
|
||||
t.Error("HTMX response should not contain DOCTYPE")
|
||||
}
|
||||
if !contains(body, "Page Not Found") {
|
||||
t.Error("expected body to contain 'Page Not Found'")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && searchString(s, substr)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{{define "content"}}
|
||||
<div class="error-page">
|
||||
<div class="error-icon">
|
||||
{{if eq .Code 404}}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="64" height="64">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
<line x1="8" y1="11" x2="14" y2="11"/>
|
||||
</svg>
|
||||
{{else}}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="64" height="64">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
|
||||
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
{{end}}
|
||||
</div>
|
||||
<h1 class="error-code">{{.Code}}</h1>
|
||||
<p class="error-title">{{.Title}}</p>
|
||||
<p class="error-message">{{.Message}}</p>
|
||||
<a href="/" class="error-home-link">Go to Dashboard</a>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user