v3: error cards across the app, friendly network errors, code quality pass

This commit is contained in:
khannurien
2026-03-21 19:17:23 +00:00
parent 608c6bc6a8
commit 5bed03baa5
21 changed files with 206 additions and 121 deletions

View File

@@ -22,6 +22,7 @@ import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
import { Markdown } from "../components/Markdown.tsx";
import { CommentThread } from "../components/CommentThread.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
type DumpState =
| { status: "loading" }
@@ -72,9 +73,10 @@ export function Dump() {
cache: "no-store",
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const apiResponse = await res.json();
if (!apiResponse.success) {
throw new Error(apiResponse.error?.message ?? "Failed to load dump");
}
const dump: Dump = deserializeDump(apiResponse.data);
setDumpState({ status: "loaded", dump });
@@ -83,10 +85,7 @@ export function Dump() {
.then((r) => r.success && setOp(deserializePublicUser(r.data)))
.catch(() => {});
} catch (err) {
setDumpState({
status: "error",
error: err instanceof Error ? err.message : "Failed to load dump",
});
setDumpState({ status: "error", error: friendlyFetchError(err) });
}
})();
}, [selectedDump, preloaded]);

View File

@@ -9,6 +9,8 @@ import { formatBytes } from "../utils/format.ts";
import { PageShell } from "../components/PageShell.tsx";
import RichContentCard from "../components/RichContentCard.tsx";
import { MediaPlayer } from "../components/MediaPlayer.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
const MAX_FILE_SIZE = 50 * 1024 * 1024;
@@ -140,19 +142,17 @@ export function DumpCreate() {
});
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const apiResponse = await res.json();
if (apiResponse.success) {
navigate(`/dumps/${apiResponse.data.id}`);
} else {
setState({ status: "error", error: apiResponse.error.message });
setState({
status: "error",
error: apiResponse.error?.message ?? "Failed to create dump.",
});
}
} catch (err) {
setState({
status: "error",
error: err instanceof Error ? err.message : "Failed to create dump.",
});
setState({ status: "error", error: friendlyFetchError(err) });
}
};
@@ -226,7 +226,7 @@ export function DumpCreate() {
<form onSubmit={handleSubmit} className="dump-create-form dump-form">
{state.status === "error" && (
<p className="form-error">{state.error}</p>
<ErrorCard title="Failed to post" message={state.error} />
)}
{mode === "url"

View File

@@ -8,6 +8,7 @@ import { useRequiredAuth } from "../hooks/useAuth.ts";
import { formatBytes } from "../utils/format.ts";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import { ConfirmModal } from "../components/ConfirmModal.tsx";
import RichContentCard from "../components/RichContentCard.tsx";
import FilePreview from "../components/FilePreview.tsx";
@@ -41,8 +42,6 @@ export function DumpEdit() {
cache: "no-store",
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const apiResponse = await res.json();
if (apiResponse.success) {
@@ -52,13 +51,13 @@ export function DumpEdit() {
setIsPrivate(dump.isPrivate);
setState({ status: "loaded", dump });
} else {
setState({ status: "error", error: apiResponse.error.message });
setState({
status: "error",
error: apiResponse.error?.message ?? "Failed to load.",
});
}
} catch (err) {
setState({
status: "error",
error: err instanceof Error ? err.message : "Load failed",
});
setState({ status: "error", error: friendlyFetchError(err) });
}
})();
}, [selectedDump]);
@@ -90,16 +89,11 @@ export function DumpEdit() {
});
}
if (!res.ok) {
setState({ status: "error", error: `Update failed (${res.status})` });
return;
}
const apiResponse = await res.json();
if (!apiResponse.success) {
setState({
status: "error",
error: apiResponse.error?.message ?? "Update failed",
error: apiResponse.error?.message ?? "Update failed.",
});
return;
}

View File

@@ -21,6 +21,8 @@ import {
type User,
} from "../model.ts";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
@@ -84,7 +86,7 @@ function FollowedSubFeed({
return <p className="index-status">Loading</p>;
}
if (state.status === "error") {
return <p className="index-status index-status--error">{state.error}</p>;
return <ErrorCard title="Failed to load" message={state.error} />;
}
const visible = state.dumps.filter((d) => !deletedDumpIds.has(d.id));
@@ -210,7 +212,7 @@ export function Index() {
} catch (err) {
setDumpsState({
status: "error",
error: err instanceof Error ? err.message : "Failed to load",
error: friendlyFetchError(err),
});
}
})();
@@ -248,7 +250,7 @@ export function Index() {
.catch((err) =>
setFollowedUsersDumps({
status: "error",
error: err instanceof Error ? err.message : "Failed to load",
error: friendlyFetchError(err),
})
);
}
@@ -284,7 +286,7 @@ export function Index() {
.catch((err) =>
setFollowedPlaylistsDumps({
status: "error",
error: err instanceof Error ? err.message : "Failed to load",
error: friendlyFetchError(err),
})
);
}
@@ -577,6 +579,7 @@ export function Index() {
{tabBar}
</div>
}
disableNew={dumpsState.status === "error"}
/>
{/* Shown only on narrow viewports */}
@@ -589,7 +592,7 @@ export function Index() {
{tab !== "followed" && (
<>
{loading && <p className="index-status">Loading</p>}
{error && <p className="index-status index-status--error">{error}</p>}
{error && <ErrorCard title="Failed to load" message={error} />}
{!loading && !error && combined.length === 0 && (
<p className="index-status">No dumps yet. Be the first!</p>

View File

@@ -3,6 +3,7 @@ import { Link } from "react-router";
import { API_URL } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { useWS } from "../hooks/useWS.ts";
import type {
DumpUpvotedData,
@@ -17,6 +18,7 @@ import type {
} from "../model.ts";
import { deserializeNotification } from "../model.ts";
import { PageShell } from "../components/PageShell.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
const PAGE_SIZE = 30;
@@ -210,7 +212,9 @@ export function Notifications() {
);
})
.catch((err) => {
if (err instanceof Error && err.message === "Failed to load") {
if (err instanceof TypeError) {
setState({ status: "error", error: friendlyFetchError(err) });
} else if (err instanceof Error && err.message === "Failed to load") {
setState({ status: "error", error: err.message });
}
});
@@ -277,8 +281,9 @@ export function Notifications() {
</div>
{state.status === "loading" && <p className="page-loading">Loading</p>}
{state.status === "error" && <p className="form-error">{state.error}
</p>}
{state.status === "error" && (
<ErrorCard title="Failed to load" message={state.error} />
)}
{state.status === "loaded" && state.items.length === 0 && (
<div className="notifications-empty">

View File

@@ -13,6 +13,8 @@ import { ConfirmModal } from "../components/ConfirmModal.tsx";
import { ImagePicker } from "../components/ImagePicker.tsx";
import { Markdown } from "../components/Markdown.tsx";
import { FollowPlaylistButton } from "../components/FollowButton.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
type LoadState =
| { status: "loading" }
@@ -94,7 +96,7 @@ export function PlaylistDetail() {
.catch((err) => {
setState({
status: "error",
error: err instanceof Error ? err.message : "Failed to load",
error: friendlyFetchError(err),
});
});
};
@@ -384,7 +386,7 @@ export function PlaylistDetail() {
setEditOpen(false);
fetchPlaylist();
} catch (err) {
setEditError(err instanceof Error ? err.message : "Save failed");
setEditError(friendlyFetchError(err));
} finally {
setEditSaving(false);
}
@@ -578,7 +580,9 @@ export function PlaylistDetail() {
</>
)}
</div>
{editError && <p className="form-error">{editError}</p>}
{editError && (
<ErrorCard title="Failed to save" message={editError} />
)}
</div>
</div>
</div>

View File

@@ -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]);

View File

@@ -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() {
<h1 className="auth-card-title">Log in</h1>
{state.status === "error" && (
<div className="error-banner">{state.error}</div>
<ErrorCard title="Login failed" message={state.error} />
)}
<form onSubmit={handleSubmit} className="auth-form">

View File

@@ -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 {
PaginatedData,
Playlist,
@@ -105,7 +106,7 @@ export function UserPlaylists() {
.catch((err) =>
setState({
status: "error",
error: err instanceof Error ? err.message : "Failed to load",
error: friendlyFetchError(err),
})
);
return;
@@ -148,7 +149,7 @@ export function UserPlaylists() {
.catch((err) =>
setState({
status: "error",
error: err instanceof Error ? err.message : "Failed to load",
error: friendlyFetchError(err),
})
);
}, [username]);

View File

@@ -24,6 +24,8 @@ import { deserializePlaylist } from "../model.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { DumpCreateModal } from "../components/DumpCreateModal.tsx";
import { FollowUserButton } from "../components/FollowButton.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
const PAGE_SIZE = 20;
@@ -72,7 +74,7 @@ function InviteButton() {
<button type="button" className="invite-btn" onClick={generate}>
+ Invite someone
</button>
{error && <p className="form-error">{error}</p>}
{error && <ErrorCard title="Failed to generate invite" message={error} />}
</div>
);
}
@@ -181,9 +183,7 @@ export function UserPublicProfile() {
.catch((err) =>
setState({
status: "error",
error: err instanceof Error
? err.message
: "Failed to load profile",
error: friendlyFetchError(err),
})
);
return;
@@ -254,7 +254,7 @@ export function UserPublicProfile() {
} catch (err) {
setState({
status: "error",
error: err instanceof Error ? err.message : "Failed to load profile",
error: friendlyFetchError(err),
});
}
})();
@@ -551,7 +551,9 @@ export function UserPublicProfile() {
O.G.
</p>
)}
{avatarError && <p className="form-error">{avatarError}</p>}
{avatarError && (
<ErrorCard title="Failed to update avatar" message={avatarError} />
)}
{!isOwnProfile && (
<FollowUserButton
targetUserId={profileUser.id}

View File

@@ -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 TokenState =
| { status: "checking" }
@@ -67,10 +69,7 @@ export function UserRegister() {
});
}
} catch (err) {
setFormState({
status: "error",
error: err instanceof Error ? err.message : "Registration failed.",
});
setFormState({ status: "error", error: friendlyFetchError(err) });
}
};
@@ -85,11 +84,11 @@ export function UserRegister() {
if (tokenState.status === "invalid") {
return (
<PageShell centered>
<div className="auth-card">
<h1 className="auth-card-title">Invalid invite</h1>
<p className="auth-card-footer">
This invite link is missing, expired, or already used.
</p>
<div className="page-error-wrap">
<ErrorCard
title="Invalid invite"
message="This invite link is missing, expired, or already used."
/>
</div>
</PageShell>
);
@@ -101,7 +100,7 @@ export function UserRegister() {
<h1 className="auth-card-title">Register</h1>
{formState.status === "error" && (
<div className="error-banner">{formState.error}</div>
<ErrorCard title="Registration failed" message={formState.error} />
)}
<form onSubmit={handleSubmit} className="auth-form">

View File

@@ -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";
@@ -83,7 +84,7 @@ export function UserUpvoted() {
.catch((err) =>
setState({
status: "error",
error: err instanceof Error ? err.message : "Failed to load",
error: friendlyFetchError(err),
})
);
return;
@@ -121,7 +122,7 @@ export function UserUpvoted() {
.catch((err) =>
setState({
status: "error",
error: err instanceof Error ? err.message : "Failed to load",
error: friendlyFetchError(err),
})
);
}, [username]);