v3: error cards across the app, friendly network errors, code quality pass
This commit is contained in:
100
src/App.css
100
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;
|
||||
}
|
||||
|
||||
@@ -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<HTMLElement>(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
|
||||
</button>
|
||||
|
||||
@@ -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 && <p className="comment-form-error">{replyError}</p>}
|
||||
{replyError && (
|
||||
<ErrorCard title="Failed to post reply" message={replyError} />
|
||||
)}
|
||||
<div className="comment-form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -323,7 +326,7 @@ export function CommentThread({
|
||||
rows={3}
|
||||
/>
|
||||
{topLevelError && (
|
||||
<p className="comment-form-error">{topLevelError}</p>
|
||||
<ErrorCard title="Failed to post comment" message={topLevelError} />
|
||||
)}
|
||||
<div className="comment-form-actions">
|
||||
<button
|
||||
|
||||
@@ -18,6 +18,8 @@ import RichContentCard from "./RichContentCard.tsx";
|
||||
import { MediaPlayer } from "./MediaPlayer.tsx";
|
||||
import type { RichContent } from "../model.ts";
|
||||
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
|
||||
import { ErrorCard } from "./ErrorCard.tsx";
|
||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||
|
||||
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();
|
||||
if (apiResponse.success) {
|
||||
const dump = deserializeDump(apiResponse.data as RawDump);
|
||||
@@ -244,14 +244,11 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
||||
} else {
|
||||
setSubmitState({
|
||||
status: "error",
|
||||
error: apiResponse.error.message,
|
||||
error: apiResponse.error?.message ?? "Failed to create dump.",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setSubmitState({
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Failed to create dump.",
|
||||
});
|
||||
setSubmitState({ status: "error", error: friendlyFetchError(err) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -340,7 +337,10 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
||||
|
||||
<form onSubmit={handleSubmit} className="dump-form">
|
||||
{submitState.status === "error" && (
|
||||
<p className="form-error">{submitState.error}</p>
|
||||
<ErrorCard
|
||||
title="Failed to post"
|
||||
message={submitState.error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === "url"
|
||||
|
||||
20
src/components/ErrorCard.tsx
Normal file
20
src/components/ErrorCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { PageShell } from "./PageShell.tsx";
|
||||
import { ErrorCard } from "./ErrorCard.tsx";
|
||||
|
||||
export function PageError({ message, actions }: {
|
||||
export function PageError(
|
||||
{ title = "Something went wrong", message, actions }: {
|
||||
title?: string;
|
||||
message: string;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
},
|
||||
) {
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="page-error">
|
||||
<h2>Error</h2>
|
||||
<p>{message}</p>
|
||||
{actions && <div className="page-error-actions">{actions}</div>}
|
||||
<div className="page-error-wrap">
|
||||
<ErrorCard title={title} message={message} actions={actions} />
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { API_URL } from "../config/api.ts";
|
||||
import type { Playlist, RawPlaylist } from "../model.ts";
|
||||
import { deserializePlaylist } from "../model.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { ErrorCard } from "./ErrorCard.tsx";
|
||||
|
||||
interface PlaylistCreateFormProps {
|
||||
/** If provided, the new playlist will have this dump added to it. */
|
||||
@@ -88,7 +89,7 @@ export function PlaylistCreateForm(
|
||||
Private
|
||||
</button>
|
||||
</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-right">
|
||||
<button
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
--color-text-muted: #94a3b8;
|
||||
--color-bg: #ffffff;
|
||||
--color-surface: #f9f9f9;
|
||||
--color-border: #646cff;
|
||||
--color-border: transparent;
|
||||
--color-link-hover: #747bff;
|
||||
--color-option-bg: #f5f5f5;
|
||||
--color-option-border: #cccccc;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,20 +142,18 @@ 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 });
|
||||
}
|
||||
} catch (err) {
|
||||
setState({
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Failed to create dump.",
|
||||
error: apiResponse.error?.message ?? "Failed to create dump.",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setState({ status: "error", error: friendlyFetchError(err) });
|
||||
}
|
||||
};
|
||||
|
||||
const submitting = state.status === "submitting";
|
||||
@@ -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"
|
||||
|
||||
@@ -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,14 +51,14 @@ export function DumpEdit() {
|
||||
setIsPrivate(dump.isPrivate);
|
||||
setState({ status: "loaded", dump });
|
||||
} else {
|
||||
setState({ status: "error", error: apiResponse.error.message });
|
||||
}
|
||||
} catch (err) {
|
||||
setState({
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Load failed",
|
||||
error: apiResponse.error?.message ?? "Failed to load.",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,22 +36,20 @@ 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 });
|
||||
}
|
||||
} catch (err) {
|
||||
setState({
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Login failed.",
|
||||
error: apiResponse.error?.message ?? "Login failed.",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setState({ status: "error", error: friendlyFetchError(err) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -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">
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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]);
|
||||
|
||||
10
src/utils/apiError.ts
Normal file
10
src/utils/apiError.ts
Normal 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.";
|
||||
}
|
||||
Reference in New Issue
Block a user