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 && ( + + )}
- {error &&

{error}

} + {error && }
} + disableNew={dumpsState.status === "error"} /> {/* Shown only on narrow viewports */} @@ -589,7 +592,7 @@ export function Index() { {tab !== "followed" && ( <> {loading &&

Loading…

} - {error &&

{error}

} + {error && } {!loading && !error && combined.length === 0 && (

No dumps yet. Be the first!

diff --git a/src/pages/Notifications.tsx b/src/pages/Notifications.tsx index cc68a61..02764eb 100644 --- a/src/pages/Notifications.tsx +++ b/src/pages/Notifications.tsx @@ -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() {
{state.status === "loading" &&

Loading…

} - {state.status === "error" &&

{state.error} -

} + {state.status === "error" && ( + + )} {state.status === "loaded" && state.items.length === 0 && (
diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 3996f45..a221940 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -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() { )}
- {editError &&

{editError}

} + {editError && ( + + )} 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}
+ )} diff --git a/src/pages/UserPlaylists.tsx b/src/pages/UserPlaylists.tsx index b56cf2c..a0389d6 100644 --- a/src/pages/UserPlaylists.tsx +++ b/src/pages/UserPlaylists.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 { 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]); diff --git a/src/pages/UserPublicProfile.tsx b/src/pages/UserPublicProfile.tsx index fcefa8d..cd78784 100644 --- a/src/pages/UserPublicProfile.tsx +++ b/src/pages/UserPublicProfile.tsx @@ -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() { - {error &&

{error}

} + {error && } ); } @@ -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.

)} - {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}
+ )} diff --git a/src/pages/UserUpvoted.tsx b/src/pages/UserUpvoted.tsx index 1454c38..c295d81 100644 --- a/src/pages/UserUpvoted.tsx +++ b/src/pages/UserUpvoted.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"; @@ -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]); diff --git a/src/utils/apiError.ts b/src/utils/apiError.ts new file mode 100644 index 0000000..45de4b3 --- /dev/null +++ b/src/utils/apiError.ts @@ -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."; +}