mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add connection status banner component
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
import { CheckCircle2, Loader2, WifiOff } from "lucide-react";
|
||||||
|
import { useConnectionStore } from "@/stores/connection-store";
|
||||||
|
|
||||||
|
export function ConnectionBanner() {
|
||||||
|
const status = useConnectionStore((s) => s.status);
|
||||||
|
|
||||||
|
if (status === "connected") return null;
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
disconnected: {
|
||||||
|
bg: "bg-amber-500 dark:bg-amber-600",
|
||||||
|
text: "text-amber-950 dark:text-amber-50",
|
||||||
|
icon: <Loader2 className="h-4 w-4 animate-spin" />,
|
||||||
|
message: "Reconnecting to server\u2026",
|
||||||
|
},
|
||||||
|
offline: {
|
||||||
|
bg: "bg-amber-500 dark:bg-amber-600",
|
||||||
|
text: "text-amber-950 dark:text-amber-50",
|
||||||
|
icon: <WifiOff className="h-4 w-4" />,
|
||||||
|
message: "You\u2019re offline",
|
||||||
|
},
|
||||||
|
reconnected: {
|
||||||
|
bg: "bg-emerald-500 dark:bg-emerald-600",
|
||||||
|
text: "text-emerald-950 dark:text-emerald-50",
|
||||||
|
icon: <CheckCircle2 className="h-4 w-4" />,
|
||||||
|
message: "Connected",
|
||||||
|
},
|
||||||
|
}[status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className={`fixed top-0 left-0 right-0 z-[60] flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium transition-transform duration-300 ${config.bg} ${config.text}`}
|
||||||
|
>
|
||||||
|
{config.icon}
|
||||||
|
{config.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", vi.fn());
|
||||||
|
|
||||||
|
import { useConnectionStore } from "@/stores/connection-store";
|
||||||
|
|
||||||
|
describe("ConnectionBanner", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
useConnectionStore.setState({ status: "connected", failedSince: null, lastHealthCheck: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when connected", async () => {
|
||||||
|
const { renderBanner } = await setupRender();
|
||||||
|
useConnectionStore.setState({ status: "connected" });
|
||||||
|
const { container } = renderBanner();
|
||||||
|
expect(container.innerHTML).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders amber banner when disconnected", async () => {
|
||||||
|
const { renderBanner } = await setupRender();
|
||||||
|
useConnectionStore.setState({ status: "disconnected" });
|
||||||
|
const { container } = renderBanner();
|
||||||
|
expect(container.textContent).toContain("Reconnecting to server");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders offline banner when offline", async () => {
|
||||||
|
const { renderBanner } = await setupRender();
|
||||||
|
useConnectionStore.setState({ status: "offline" });
|
||||||
|
const { container } = renderBanner();
|
||||||
|
expect(container.textContent).toContain("offline");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders green banner when reconnected", async () => {
|
||||||
|
const { renderBanner } = await setupRender();
|
||||||
|
useConnectionStore.setState({ status: "reconnected" });
|
||||||
|
const { container } = renderBanner();
|
||||||
|
expect(container.textContent).toContain("Connected");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setupRender() {
|
||||||
|
const React = await import("react");
|
||||||
|
const { render } = await import("@testing-library/react");
|
||||||
|
const { ConnectionBanner } = await import("@/components/common/connection-banner");
|
||||||
|
return {
|
||||||
|
renderBanner: () => render(React.createElement(ConnectionBanner)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ import { defineConfig } from "vitest/config";
|
|||||||
// Resolve api-workspace packages that pnpm only exposes under apps/api/node_modules.
|
// Resolve api-workspace packages that pnpm only exposes under apps/api/node_modules.
|
||||||
const apiNodeModules = path.resolve(__dirname, "apps/api/node_modules");
|
const apiNodeModules = path.resolve(__dirname, "apps/api/node_modules");
|
||||||
|
|
||||||
|
// Resolve web-workspace packages that pnpm only exposes under apps/web/node_modules.
|
||||||
|
const webNodeModules = path.resolve(__dirname, "apps/web/node_modules");
|
||||||
|
|
||||||
// Temp dir for integration test DB + workspace (set BEFORE any app code loads)
|
// Temp dir for integration test DB + workspace (set BEFORE any app code loads)
|
||||||
const testDir = path.join(os.tmpdir(), `ashim-test-${crypto.randomUUID().slice(0, 8)}`);
|
const testDir = path.join(os.tmpdir(), `ashim-test-${crypto.randomUUID().slice(0, 8)}`);
|
||||||
|
|
||||||
@@ -88,6 +91,10 @@ export default defineConfig({
|
|||||||
jsqr: path.join(apiNodeModules, "jsqr"),
|
jsqr: path.join(apiNodeModules, "jsqr"),
|
||||||
pdfkit: path.join(apiNodeModules, "pdfkit"),
|
pdfkit: path.join(apiNodeModules, "pdfkit"),
|
||||||
sharp: path.join(apiNodeModules, "sharp"),
|
sharp: path.join(apiNodeModules, "sharp"),
|
||||||
|
// Map web-only dependencies so component tests can resolve them
|
||||||
|
// from the root vitest runner.
|
||||||
|
react: path.join(webNodeModules, "react"),
|
||||||
|
"react-dom": path.join(webNodeModules, "react-dom"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user