m4: global API-error surface (no more silent failures)
The api client now catches network failures and non-JSON bodies robustly, and routes unexpected errors (offline / 5xx) to a global toast — 4xx stay with the caller so forms keep their inline messages. Toast actions are now optional (undo toasts keep their button; error toasts are message-only), and ToastHost moved from AppShell to the app root so toasts show everywhere, including the login screen. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
@@ -1,3 +1,8 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ToastHost from "./components/ToastHost.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
|
<ToastHost />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,25 +1,52 @@
|
|||||||
|
import { useUiStore } from "../stores/ui";
|
||||||
|
|
||||||
export interface ApiError {
|
export interface ApiError {
|
||||||
error: string;
|
error: string;
|
||||||
status: number;
|
status: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Surface infra / unexpected failures (network, 5xx) globally. 4xx are left to
|
||||||
|
// the caller — forms and views show their own inline messages for those.
|
||||||
|
function notifyError(err: ApiError) {
|
||||||
|
try {
|
||||||
|
useUiStore().showToast(err.error);
|
||||||
|
} catch {
|
||||||
|
/* pinia not active yet (very early boot) — skip the toast */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
const resp = await fetch(path, {
|
let resp: Response;
|
||||||
|
try {
|
||||||
|
resp = await fetch(path, {
|
||||||
method,
|
method,
|
||||||
credentials: "include", // send/receive the signed session cookie
|
credentials: "include", // send/receive the signed session cookie
|
||||||
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
|
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
|
||||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
|
} catch {
|
||||||
|
const err: ApiError = { error: "Can't reach the server. Check your connection.", status: 0 };
|
||||||
|
notifyError(err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
const text = await resp.text();
|
const text = await resp.text();
|
||||||
const data: unknown = text ? JSON.parse(text) : {};
|
let data: unknown = {};
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
data = {}; // non-JSON body (e.g. a proxy error page) — fall through to status handling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const message =
|
const message =
|
||||||
typeof data === "object" && data !== null && "error" in data
|
typeof data === "object" && data !== null && "error" in data
|
||||||
? String((data as { error: unknown }).error)
|
? String((data as { error: unknown }).error)
|
||||||
: "Request failed.";
|
: "Something went wrong. Please try again.";
|
||||||
const err: ApiError = { error: message, status: resp.status };
|
const err: ApiError = { error: message, status: resp.status };
|
||||||
|
if (resp.status >= 500) notifyError(err);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { useUiStore } from "../stores/ui";
|
|||||||
import CommandPalette from "./CommandPalette.vue";
|
import CommandPalette from "./CommandPalette.vue";
|
||||||
import Icon from "./Icon.vue";
|
import Icon from "./Icon.vue";
|
||||||
import LabelsModal from "./LabelsModal.vue";
|
import LabelsModal from "./LabelsModal.vue";
|
||||||
import ToastHost from "./ToastHost.vue";
|
|
||||||
import { NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
|
import { NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -297,8 +296,6 @@ async function signOut() {
|
|||||||
|
|
||||||
<CommandPalette v-if="paletteOpen" @close="paletteOpen = false" />
|
<CommandPalette v-if="paletteOpen" @close="paletteOpen = false" />
|
||||||
|
|
||||||
<ToastHost />
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="showShortcuts"
|
v-if="showShortcuts"
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||||
|
|||||||
@@ -19,11 +19,12 @@ const ui = useUiStore();
|
|||||||
>
|
>
|
||||||
<span>{{ ui.toast.message }}</span>
|
<span>{{ ui.toast.message }}</span>
|
||||||
<button
|
<button
|
||||||
|
v-if="ui.toast.action"
|
||||||
type="button"
|
type="button"
|
||||||
class="font-semibold text-brand hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
class="font-semibold text-brand hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||||||
@click="ui.runToastAction()"
|
@click="ui.runToastAction()"
|
||||||
>
|
>
|
||||||
{{ ui.toast.actionLabel }}
|
{{ ui.toast.action.label }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -106,7 +106,8 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
const setPinned = (id: string, pinned: boolean) => mutate(id, { pinned });
|
const setPinned = (id: string, pinned: boolean) => mutate(id, { pinned });
|
||||||
const setArchived = async (id: string, archived: boolean): Promise<void> => {
|
const setArchived = async (id: string, archived: boolean): Promise<void> => {
|
||||||
await mutate(id, { archived });
|
await mutate(id, { archived });
|
||||||
if (archived) useUiStore().showToast("Note archived", "Undo", () => void setArchived(id, false));
|
if (archived)
|
||||||
|
useUiStore().showToast("Note archived", { label: "Undo", run: () => void setArchived(id, false) });
|
||||||
};
|
};
|
||||||
const setColor = (id: string, color: NoteColor) => mutate(id, { color });
|
const setColor = (id: string, color: NoteColor) => mutate(id, { color });
|
||||||
const setKind = (id: string, kind: NoteKind) => mutate(id, { kind });
|
const setKind = (id: string, kind: NoteKind) => mutate(id, { kind });
|
||||||
@@ -176,7 +177,7 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
|
|
||||||
async function trash(id: string): Promise<void> {
|
async function trash(id: string): Promise<void> {
|
||||||
reconcile(await api.post<Note>(`/api/notes/${id}/trash`));
|
reconcile(await api.post<Note>(`/api/notes/${id}/trash`));
|
||||||
useUiStore().showToast("Note moved to trash", "Undo", () => void restore(id));
|
useUiStore().showToast("Note moved to trash", { label: "Undo", run: () => void restore(id) });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function restore(id: string): Promise<void> {
|
async function restore(id: string): Promise<void> {
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
interface ToastAction {
|
||||||
|
label: string;
|
||||||
|
run: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface Toast {
|
interface Toast {
|
||||||
id: number;
|
id: number;
|
||||||
message: string;
|
message: string;
|
||||||
actionLabel: string;
|
action?: ToastAction;
|
||||||
action: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cross-component UI signals that don't belong to any single view (e.g. a
|
// Cross-component UI signals that don't belong to any single view (e.g. a
|
||||||
@@ -22,10 +26,10 @@ export const useUiStore = defineStore("ui", () => {
|
|||||||
let toastTimer: ReturnType<typeof setTimeout> | undefined;
|
let toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
let toastSeq = 0;
|
let toastSeq = 0;
|
||||||
|
|
||||||
function showToast(message: string, actionLabel: string, action: () => void) {
|
function showToast(message: string, action?: ToastAction) {
|
||||||
toastSeq += 1;
|
toastSeq += 1;
|
||||||
const id = toastSeq;
|
const id = toastSeq;
|
||||||
toast.value = { id, message, actionLabel, action };
|
toast.value = { id, message, action };
|
||||||
clearTimeout(toastTimer);
|
clearTimeout(toastTimer);
|
||||||
toastTimer = setTimeout(() => {
|
toastTimer = setTimeout(() => {
|
||||||
if (toast.value?.id === id) toast.value = null;
|
if (toast.value?.id === id) toast.value = null;
|
||||||
@@ -38,9 +42,9 @@ export const useUiStore = defineStore("ui", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function runToastAction() {
|
function runToastAction() {
|
||||||
const action = toast.value?.action;
|
const run = toast.value?.action?.run;
|
||||||
dismissToast();
|
dismissToast();
|
||||||
action?.();
|
run?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { composeTick, requestCompose, toast, showToast, dismissToast, runToastAction };
|
return { composeTick, requestCompose, toast, showToast, dismissToast, runToastAction };
|
||||||
|
|||||||
Reference in New Issue
Block a user