From 5bed03baa5e10270765aa743370d9655fecb66ff Mon Sep 17 00:00:00 2001
From: khannurien
Date: Sat, 21 Mar 2026 19:17:23 +0000
Subject: [PATCH] v3: error cards across the app, friendly network errors, code
quality pass
---
src/App.css | 100 +++++++++++++++++---------
src/components/AppHeader.tsx | 6 +-
src/components/CommentThread.tsx | 7 +-
src/components/DumpCreateModal.tsx | 16 ++---
src/components/ErrorCard.tsx | 20 ++++++
src/components/PageError.tsx | 18 ++---
src/components/PlaylistCreateForm.tsx | 3 +-
src/index.css | 2 +-
src/pages/Dump.tsx | 11 ++-
src/pages/DumpCreate.tsx | 16 ++---
src/pages/DumpEdit.tsx | 20 ++----
src/pages/Index.tsx | 13 ++--
src/pages/Notifications.tsx | 11 ++-
src/pages/PlaylistDetail.tsx | 10 ++-
src/pages/UserDumps.tsx | 5 +-
src/pages/UserLogin.tsx | 16 ++---
src/pages/UserPlaylists.tsx | 5 +-
src/pages/UserPublicProfile.tsx | 14 ++--
src/pages/UserRegister.tsx | 19 +++--
src/pages/UserUpvoted.tsx | 5 +-
src/utils/apiError.ts | 10 +++
21 files changed, 206 insertions(+), 121 deletions(-)
create mode 100644 src/components/ErrorCard.tsx
create mode 100644 src/utils/apiError.ts
diff --git a/src/App.css b/src/App.css
index c09aca0..9731d59 100644
--- a/src/App.css
+++ b/src/App.css
@@ -1205,6 +1205,60 @@ body.has-player .fab-new {
color: var(--color-danger);
}
+/* ── ErrorCard ── */
+.error-card-wrap {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.error-card {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.6rem;
+ background: var(--color-danger-bg);
+ color: var(--color-on-accent);
+ padding: 0.7rem 1rem;
+ border-radius: 8px;
+ font-size: 0.9rem;
+}
+
+.error-card-icon {
+ flex-shrink: 0;
+ line-height: 1.45;
+}
+
+.error-card-body {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+}
+
+.error-card-title {
+ margin: 0;
+ font-size: 0.95rem;
+ font-weight: 700;
+}
+
+.error-card-message {
+ margin: 0;
+ line-height: 1.45;
+ opacity: 0.85;
+}
+
+.error-card-actions {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ flex-wrap: wrap;
+}
+
+.page-error-wrap {
+ margin: 2rem auto;
+ max-width: 480px;
+ width: 100%;
+}
+
.form-error {
color: var(--color-danger);
margin: 0;
@@ -1240,27 +1294,6 @@ body.has-player .fab-new {
padding: 4rem 1rem;
}
-.page-error {
- display: flex;
- flex-direction: column;
- gap: 0.75rem;
- padding: 2rem 0;
-}
-
-.page-error-actions {
- display: flex;
- align-items: center;
- gap: 1rem;
-}
-
-.error-banner {
- background: var(--color-danger-bg);
- color: var(--color-on-accent);
- padding: 0.6rem 1rem;
- border-radius: 8px;
- font-size: 0.9rem;
-}
-
/* ── Shared header ── */
.app-header {
display: flex;
@@ -1552,6 +1585,12 @@ body.has-player .fab-new {
box-shadow: none;
}
+.btn-primary:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+ pointer-events: none;
+}
+
.btn-secondary {
background: none;
border: 1px solid var(--color-border);
@@ -1635,11 +1674,6 @@ body.has-player .fab-new {
padding: 3rem 1rem;
}
-.index-status--error {
- color: var(--color-danger);
- opacity: 1;
-}
-
/* ── Below-header strip (presence + sort on narrow viewports) ── */
.index-below-header {
display: flex;
@@ -1695,6 +1729,14 @@ body.has-player .fab-new {
}
}
+.index-page .error-card-wrap {
+ max-width: 860px;
+ width: 100%;
+ padding: 1rem 1.25rem 0;
+ box-sizing: border-box;
+ align-self: center;
+}
+
.dump-feed {
list-style: none;
margin: 0;
@@ -2580,12 +2622,6 @@ body.has-player .fab-new {
cursor: default;
}
-.comment-form-error {
- margin: 0;
- font-size: 0.8rem;
- color: var(--color-danger);
-}
-
.comment-node-inner--deleted {
opacity: 0.35;
}
diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx
index 8972eae..ddcdab8 100644
--- a/src/components/AppHeader.tsx
+++ b/src/components/AppHeader.tsx
@@ -4,7 +4,9 @@ import { useAuth } from "../hooks/useAuth.ts";
import { DumpCreateModal } from "./DumpCreateModal.tsx";
import { NotificationBell } from "./NotificationBell.tsx";
-export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
+export function AppHeader(
+ { centerSlot, disableNew }: { centerSlot?: ReactNode; disableNew?: boolean },
+) {
const { user } = useAuth();
const navigate = useNavigate();
const headerRef = useRef(null);
@@ -55,6 +57,8 @@ export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
type="button"
className="btn-primary"
onClick={() => setCreateModalOpen(true)}
+ disabled={disableNew}
+ title={disableNew ? "Server unreachable" : undefined}
>
+ New
diff --git a/src/components/CommentThread.tsx b/src/components/CommentThread.tsx
index 05512e5..0981ca3 100644
--- a/src/components/CommentThread.tsx
+++ b/src/components/CommentThread.tsx
@@ -6,6 +6,7 @@ import { deserializeComment } from "../model.ts";
import { Avatar } from "./Avatar.tsx";
import { Markdown } from "./Markdown.tsx";
import { relativeTime } from "../utils/relativeTime.ts";
+import { ErrorCard } from "./ErrorCard.tsx";
interface CommentThreadProps {
dumpId: string;
@@ -204,7 +205,9 @@ function CommentNode({
placeholder="Write a reply…"
rows={3}
/>
- {replyError && {replyError}
}
+ {replyError && (
+
+ )}
{topLevelError && (
-
{topLevelError}
+
)}
diff --git a/src/pages/UserDumps.tsx b/src/pages/UserDumps.tsx
index bc34d70..099ad34 100644
--- a/src/pages/UserDumps.tsx
+++ b/src/pages/UserDumps.tsx
@@ -8,6 +8,7 @@ import {
import { Link, useParams } from "react-router";
import { API_URL } from "../config/api.ts";
+import { friendlyFetchError } from "../utils/apiError.ts";
import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
import { deserializeDump, deserializePublicUser } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
@@ -69,7 +70,7 @@ export function UserDumps() {
.catch((err) =>
setState({
status: "error",
- error: err instanceof Error ? err.message : "Failed to load",
+ error: friendlyFetchError(err),
})
);
return;
@@ -105,7 +106,7 @@ export function UserDumps() {
.catch((err) =>
setState({
status: "error",
- error: err instanceof Error ? err.message : "Failed to load",
+ error: friendlyFetchError(err),
})
);
}, [username]);
diff --git a/src/pages/UserLogin.tsx b/src/pages/UserLogin.tsx
index 96876a6..3a7c048 100644
--- a/src/pages/UserLogin.tsx
+++ b/src/pages/UserLogin.tsx
@@ -6,6 +6,8 @@ import { API_URL } from "../config/api.ts";
import { deserializeAuthResponse } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { PageShell } from "../components/PageShell.tsx";
+import { ErrorCard } from "../components/ErrorCard.tsx";
+import { friendlyFetchError } from "../utils/apiError.ts";
type UserLoginState =
| { status: "idle" }
@@ -34,21 +36,19 @@ export function UserLogin() {
body: JSON.stringify({ username, password }),
});
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
-
const apiResponse = await res.json();
if (apiResponse.success) {
login(deserializeAuthResponse(apiResponse.data));
navigate("/");
} else {
- setState({ status: "error", error: apiResponse.error.message });
+ setState({
+ status: "error",
+ error: apiResponse.error?.message ?? "Login failed.",
+ });
}
} catch (err) {
- setState({
- status: "error",
- error: err instanceof Error ? err.message : "Login failed.",
- });
+ setState({ status: "error", error: friendlyFetchError(err) });
}
};
@@ -58,7 +58,7 @@ export function UserLogin() {
Log in
{state.status === "error" && (
- {state.error}
+
)}
)}
- {avatarError && {avatarError}
}
+ {avatarError && (
+
+ )}
{!isOwnProfile && (
-
-
Invalid invite
-
- This invite link is missing, expired, or already used.
-
+
+
);
@@ -101,7 +100,7 @@ export function UserRegister() {
Register
{formState.status === "error" && (
-
{formState.error}
+
)}