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

@@ -1205,6 +1205,60 @@ body.has-player .fab-new {
color: var(--color-danger); 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 { .form-error {
color: var(--color-danger); color: var(--color-danger);
margin: 0; margin: 0;
@@ -1240,27 +1294,6 @@ body.has-player .fab-new {
padding: 4rem 1rem; 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 ── */ /* ── Shared header ── */
.app-header { .app-header {
display: flex; display: flex;
@@ -1552,6 +1585,12 @@ body.has-player .fab-new {
box-shadow: none; box-shadow: none;
} }
.btn-primary:disabled {
opacity: 0.45;
cursor: not-allowed;
pointer-events: none;
}
.btn-secondary { .btn-secondary {
background: none; background: none;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@@ -1635,11 +1674,6 @@ body.has-player .fab-new {
padding: 3rem 1rem; padding: 3rem 1rem;
} }
.index-status--error {
color: var(--color-danger);
opacity: 1;
}
/* ── Below-header strip (presence + sort on narrow viewports) ── */ /* ── Below-header strip (presence + sort on narrow viewports) ── */
.index-below-header { .index-below-header {
display: flex; 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 { .dump-feed {
list-style: none; list-style: none;
margin: 0; margin: 0;
@@ -2580,12 +2622,6 @@ body.has-player .fab-new {
cursor: default; cursor: default;
} }
.comment-form-error {
margin: 0;
font-size: 0.8rem;
color: var(--color-danger);
}
.comment-node-inner--deleted { .comment-node-inner--deleted {
opacity: 0.35; opacity: 0.35;
} }

View File

@@ -4,7 +4,9 @@ import { useAuth } from "../hooks/useAuth.ts";
import { DumpCreateModal } from "./DumpCreateModal.tsx"; import { DumpCreateModal } from "./DumpCreateModal.tsx";
import { NotificationBell } from "./NotificationBell.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 { user } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const headerRef = useRef<HTMLElement>(null); const headerRef = useRef<HTMLElement>(null);
@@ -55,6 +57,8 @@ export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
type="button" type="button"
className="btn-primary" className="btn-primary"
onClick={() => setCreateModalOpen(true)} onClick={() => setCreateModalOpen(true)}
disabled={disableNew}
title={disableNew ? "Server unreachable" : undefined}
> >
+ New + New
</button> </button>

View File

@@ -6,6 +6,7 @@ import { deserializeComment } from "../model.ts";
import { Avatar } from "./Avatar.tsx"; import { Avatar } from "./Avatar.tsx";
import { Markdown } from "./Markdown.tsx"; import { Markdown } from "./Markdown.tsx";
import { relativeTime } from "../utils/relativeTime.ts"; import { relativeTime } from "../utils/relativeTime.ts";
import { ErrorCard } from "./ErrorCard.tsx";
interface CommentThreadProps { interface CommentThreadProps {
dumpId: string; dumpId: string;
@@ -204,7 +205,9 @@ function CommentNode({
placeholder="Write a reply…" placeholder="Write a reply…"
rows={3} rows={3}
/> />
{replyError && <p className="comment-form-error">{replyError}</p>} {replyError && (
<ErrorCard title="Failed to post reply" message={replyError} />
)}
<div className="comment-form-actions"> <div className="comment-form-actions">
<button <button
type="submit" type="submit"
@@ -323,7 +326,7 @@ export function CommentThread({
rows={3} rows={3}
/> />
{topLevelError && ( {topLevelError && (
<p className="comment-form-error">{topLevelError}</p> <ErrorCard title="Failed to post comment" message={topLevelError} />
)} )}
<div className="comment-form-actions"> <div className="comment-form-actions">
<button <button

View File

@@ -18,6 +18,8 @@ import RichContentCard from "./RichContentCard.tsx";
import { MediaPlayer } from "./MediaPlayer.tsx"; import { MediaPlayer } from "./MediaPlayer.tsx";
import type { RichContent } from "../model.ts"; import type { RichContent } from "../model.ts";
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx"; import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
import { ErrorCard } from "./ErrorCard.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
const MAX_FILE_SIZE = 50 * 1024 * 1024; const MAX_FILE_SIZE = 50 * 1024 * 1024;
@@ -219,8 +221,6 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
}); });
} }
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const apiResponse = await res.json(); const apiResponse = await res.json();
if (apiResponse.success) { if (apiResponse.success) {
const dump = deserializeDump(apiResponse.data as RawDump); const dump = deserializeDump(apiResponse.data as RawDump);
@@ -244,14 +244,11 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
} else { } else {
setSubmitState({ setSubmitState({
status: "error", status: "error",
error: apiResponse.error.message, error: apiResponse.error?.message ?? "Failed to create dump.",
}); });
} }
} catch (err) { } catch (err) {
setSubmitState({ setSubmitState({ status: "error", error: friendlyFetchError(err) });
status: "error",
error: err instanceof Error ? err.message : "Failed to create dump.",
});
} }
}; };
@@ -340,7 +337,10 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
<form onSubmit={handleSubmit} className="dump-form"> <form onSubmit={handleSubmit} className="dump-form">
{submitState.status === "error" && ( {submitState.status === "error" && (
<p className="form-error">{submitState.error}</p> <ErrorCard
title="Failed to post"
message={submitState.error}
/>
)} )}
{mode === "url" {mode === "url"

View File

@@ -0,0 +1,20 @@
import type { ReactNode } from "react";
export function ErrorCard({ title, message, actions }: {
title: string;
message: string;
actions?: ReactNode;
}) {
return (
<div className="error-card-wrap">
<div className="error-card" role="alert">
<span className="error-card-icon"></span>
<div className="error-card-body">
<h2 className="error-card-title">{title}</h2>
<p className="error-card-message">{message}</p>
</div>
</div>
{actions && <div className="error-card-actions">{actions}</div>}
</div>
);
}

View File

@@ -1,16 +1,18 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { PageShell } from "./PageShell.tsx"; import { PageShell } from "./PageShell.tsx";
import { ErrorCard } from "./ErrorCard.tsx";
export function PageError({ message, actions }: { export function PageError(
message: string; { title = "Something went wrong", message, actions }: {
actions?: ReactNode; title?: string;
}) { message: string;
actions?: ReactNode;
},
) {
return ( return (
<PageShell> <PageShell>
<div className="page-error"> <div className="page-error-wrap">
<h2>Error</h2> <ErrorCard title={title} message={message} actions={actions} />
<p>{message}</p>
{actions && <div className="page-error-actions">{actions}</div>}
</div> </div>
</PageShell> </PageShell>
); );

View File

@@ -3,6 +3,7 @@ import { API_URL } from "../config/api.ts";
import type { Playlist, RawPlaylist } from "../model.ts"; import type { Playlist, RawPlaylist } from "../model.ts";
import { deserializePlaylist } from "../model.ts"; import { deserializePlaylist } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { ErrorCard } from "./ErrorCard.tsx";
interface PlaylistCreateFormProps { interface PlaylistCreateFormProps {
/** If provided, the new playlist will have this dump added to it. */ /** If provided, the new playlist will have this dump added to it. */
@@ -88,7 +89,7 @@ export function PlaylistCreateForm(
Private Private
</button> </button>
</div> </div>
{error && <p className="form-error">{error}</p>} {error && <ErrorCard title="Failed to create playlist" message={error} />}
<div className="form-actions"> <div className="form-actions">
<div className="form-actions-right"> <div className="form-actions-right">
<button <button

View File

@@ -59,7 +59,7 @@
--color-text-muted: #94a3b8; --color-text-muted: #94a3b8;
--color-bg: #ffffff; --color-bg: #ffffff;
--color-surface: #f9f9f9; --color-surface: #f9f9f9;
--color-border: #646cff; --color-border: transparent;
--color-link-hover: #747bff; --color-link-hover: #747bff;
--color-option-bg: #f5f5f5; --color-option-bg: #f5f5f5;
--color-option-border: #cccccc; --color-option-border: #cccccc;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import {
import { Link, useParams } from "react-router"; import { Link, useParams } from "react-router";
import { API_URL } from "../config/api.ts"; import { API_URL } from "../config/api.ts";
import { friendlyFetchError } from "../utils/apiError.ts";
import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts"; import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
import { deserializeDump, deserializePublicUser } from "../model.ts"; import { deserializeDump, deserializePublicUser } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
@@ -69,7 +70,7 @@ export function UserDumps() {
.catch((err) => .catch((err) =>
setState({ setState({
status: "error", status: "error",
error: err instanceof Error ? err.message : "Failed to load", error: friendlyFetchError(err),
}) })
); );
return; return;
@@ -105,7 +106,7 @@ export function UserDumps() {
.catch((err) => .catch((err) =>
setState({ setState({
status: "error", status: "error",
error: err instanceof Error ? err.message : "Failed to load", error: friendlyFetchError(err),
}) })
); );
}, [username]); }, [username]);

View File

@@ -6,6 +6,8 @@ import { API_URL } from "../config/api.ts";
import { deserializeAuthResponse } from "../model.ts"; import { deserializeAuthResponse } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { PageShell } from "../components/PageShell.tsx"; import { PageShell } from "../components/PageShell.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
type UserLoginState = type UserLoginState =
| { status: "idle" } | { status: "idle" }
@@ -34,21 +36,19 @@ export function UserLogin() {
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),
}); });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const apiResponse = await res.json(); const apiResponse = await res.json();
if (apiResponse.success) { if (apiResponse.success) {
login(deserializeAuthResponse(apiResponse.data)); login(deserializeAuthResponse(apiResponse.data));
navigate("/"); navigate("/");
} else { } else {
setState({ status: "error", error: apiResponse.error.message }); setState({
status: "error",
error: apiResponse.error?.message ?? "Login failed.",
});
} }
} catch (err) { } catch (err) {
setState({ setState({ status: "error", error: friendlyFetchError(err) });
status: "error",
error: err instanceof Error ? err.message : "Login failed.",
});
} }
}; };
@@ -58,7 +58,7 @@ export function UserLogin() {
<h1 className="auth-card-title">Log in</h1> <h1 className="auth-card-title">Log in</h1>
{state.status === "error" && ( {state.status === "error" && (
<div className="error-banner">{state.error}</div> <ErrorCard title="Login failed" message={state.error} />
)} )}
<form onSubmit={handleSubmit} className="auth-form"> <form onSubmit={handleSubmit} className="auth-form">

View File

@@ -8,6 +8,7 @@ import {
import { Link, useParams } from "react-router"; import { Link, useParams } from "react-router";
import { API_URL } from "../config/api.ts"; import { API_URL } from "../config/api.ts";
import { friendlyFetchError } from "../utils/apiError.ts";
import type { import type {
PaginatedData, PaginatedData,
Playlist, Playlist,
@@ -105,7 +106,7 @@ export function UserPlaylists() {
.catch((err) => .catch((err) =>
setState({ setState({
status: "error", status: "error",
error: err instanceof Error ? err.message : "Failed to load", error: friendlyFetchError(err),
}) })
); );
return; return;
@@ -148,7 +149,7 @@ export function UserPlaylists() {
.catch((err) => .catch((err) =>
setState({ setState({
status: "error", status: "error",
error: err instanceof Error ? err.message : "Failed to load", error: friendlyFetchError(err),
}) })
); );
}, [username]); }, [username]);

View File

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

View File

@@ -6,6 +6,8 @@ import { API_URL } from "../config/api.ts";
import { deserializeAuthResponse } from "../model.ts"; import { deserializeAuthResponse } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { PageShell } from "../components/PageShell.tsx"; import { PageShell } from "../components/PageShell.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
type TokenState = type TokenState =
| { status: "checking" } | { status: "checking" }
@@ -67,10 +69,7 @@ export function UserRegister() {
}); });
} }
} catch (err) { } catch (err) {
setFormState({ setFormState({ status: "error", error: friendlyFetchError(err) });
status: "error",
error: err instanceof Error ? err.message : "Registration failed.",
});
} }
}; };
@@ -85,11 +84,11 @@ export function UserRegister() {
if (tokenState.status === "invalid") { if (tokenState.status === "invalid") {
return ( return (
<PageShell centered> <PageShell centered>
<div className="auth-card"> <div className="page-error-wrap">
<h1 className="auth-card-title">Invalid invite</h1> <ErrorCard
<p className="auth-card-footer"> title="Invalid invite"
This invite link is missing, expired, or already used. message="This invite link is missing, expired, or already used."
</p> />
</div> </div>
</PageShell> </PageShell>
); );
@@ -101,7 +100,7 @@ export function UserRegister() {
<h1 className="auth-card-title">Register</h1> <h1 className="auth-card-title">Register</h1>
{formState.status === "error" && ( {formState.status === "error" && (
<div className="error-banner">{formState.error}</div> <ErrorCard title="Registration failed" message={formState.error} />
)} )}
<form onSubmit={handleSubmit} className="auth-form"> <form onSubmit={handleSubmit} className="auth-form">

View File

@@ -8,6 +8,7 @@ import {
import { Link, useParams } from "react-router"; import { Link, useParams } from "react-router";
import { API_URL } from "../config/api.ts"; import { API_URL } from "../config/api.ts";
import { friendlyFetchError } from "../utils/apiError.ts";
import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts"; import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
import { deserializeDump, deserializePublicUser } from "../model.ts"; import { deserializeDump, deserializePublicUser } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
@@ -83,7 +84,7 @@ export function UserUpvoted() {
.catch((err) => .catch((err) =>
setState({ setState({
status: "error", status: "error",
error: err instanceof Error ? err.message : "Failed to load", error: friendlyFetchError(err),
}) })
); );
return; return;
@@ -121,7 +122,7 @@ export function UserUpvoted() {
.catch((err) => .catch((err) =>
setState({ setState({
status: "error", status: "error",
error: err instanceof Error ? err.message : "Failed to load", error: friendlyFetchError(err),
}) })
); );
}, [username]); }, [username]);

10
src/utils/apiError.ts Normal file
View File

@@ -0,0 +1,10 @@
/**
* Convert a caught fetch error into a human-readable message.
* TypeError means the request never left the client (no network / CORS / etc.).
*/
export function friendlyFetchError(err: unknown): string {
if (err instanceof TypeError) {
return "Could not connect to the server. Check your connection and try again.";
}
return err instanceof Error ? err.message : "An unexpected error occurred.";
}