- Home puts the dashboards beside the flows: two equal-height columns, capped and scrollable, most recently worked on first. Each tile is a schematic footprint built from the stored widget placements. - Flows and dashboards can be picked by long press or ctrl-click; the create button becomes a trash and one dialog covers the batch. - The offline banner is drawn on the body so it centres on the viewport, and the live socket now releases the offline latch a stray 503 set. - A boot spinner before React's first commit, a router pending screen for code-split pages, and skeletons where an empty list used to flash. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu
130 lines
4.4 KiB
TypeScript
130 lines
4.4 KiB
TypeScript
import {
|
|
MutationCache,
|
|
QueryCache,
|
|
QueryClient,
|
|
QueryClientProvider,
|
|
} from "@tanstack/react-query"
|
|
import { createRouter, RouterProvider } from "@tanstack/react-router"
|
|
import { MotionConfig } from "motion/react"
|
|
import { StrictMode } from "react"
|
|
import ReactDOM from "react-dom/client"
|
|
import { toast } from "sonner"
|
|
import { ApiError, OpenAPI } from "./client"
|
|
import { PageLoading } from "./components/Common/Loading"
|
|
import { ThemeProvider } from "./components/theme-provider"
|
|
import { Toaster } from "./components/ui/sonner"
|
|
import "./index.css"
|
|
import { connectionStore, offlineDetail } from "./lib/connectionStore"
|
|
import { apiToken, appPath, appRoute, portalConfig } from "./lib/portal"
|
|
import { routeTree } from "./routeTree.gen"
|
|
|
|
const portal = portalConfig()
|
|
|
|
// Served through a portal, the API is a path on this same origin and the
|
|
// credential arrives with the page. Everywhere else this is the build-time URL
|
|
// and the token in storage, exactly as before.
|
|
OpenAPI.BASE = portal
|
|
? window.location.origin + portal.apiBase
|
|
: import.meta.env.VITE_API_URL
|
|
OpenAPI.TOKEN = async () => apiToken()
|
|
|
|
/**
|
|
* A credential the server will not accept, so the session is over.
|
|
*
|
|
* Only a 401 says that. `get_current_user` answers 401 for every
|
|
* authentication failure it has, which leaves 403 meaning the opposite: signed
|
|
* in, and reaching past what this account is allowed.
|
|
*/
|
|
const isSessionGone = (error: unknown) =>
|
|
error instanceof ApiError && error.status === 401
|
|
|
|
/** Signed in, but not permitted this. Nothing to do but say so. */
|
|
const isForbidden = (error: unknown) =>
|
|
error instanceof ApiError && error.status === 403
|
|
|
|
/** Neither answer changes on a second ask, so a retry only delays the news. */
|
|
const isPointlessToRetry = (error: unknown) =>
|
|
isSessionGone(error) || isForbidden(error)
|
|
|
|
const handleApiError = (error: Error) => {
|
|
const offline = offlineDetail(error)
|
|
if (offline) {
|
|
// The installation is unreachable, not the session invalid: keep the user
|
|
// where they are and let the banner explain.
|
|
connectionStore.setOffline(offline.lastSeen)
|
|
return
|
|
}
|
|
if (isSessionGone(error)) {
|
|
// A paired wall panel has no login screen to go back to — it asks for a
|
|
// new code instead.
|
|
if (appRoute().startsWith("/panel")) {
|
|
localStorage.removeItem("access_token")
|
|
window.location.href = appPath("/panel")
|
|
return
|
|
}
|
|
if (portal) {
|
|
// The portal knows whether they are still signed in; it can mint a fresh
|
|
// handoff or send them to the login screen.
|
|
window.location.href = `${portal.portalUrl}?reauth=${portal.installationId}`
|
|
return
|
|
}
|
|
localStorage.removeItem("access_token")
|
|
window.location.href = appPath("/login")
|
|
return
|
|
}
|
|
if (isForbidden(error) && !appRoute().startsWith("/panel")) {
|
|
// The session stands, so stay put — but a refused change that says nothing
|
|
// reads as a broken button. On a panel there is nobody to read a toast: a
|
|
// 403 there is a dashboard it was just unassigned from, which its next read
|
|
// of the panel corrects on its own.
|
|
toast.error("You do not have permission to do that")
|
|
}
|
|
}
|
|
|
|
const queryClient = new QueryClient({
|
|
queryCache: new QueryCache({
|
|
onError: handleApiError,
|
|
// Any answer at all means the tunnel is up again.
|
|
onSuccess: () => connectionStore.setOnline(),
|
|
}),
|
|
mutationCache: new MutationCache({
|
|
onError: handleApiError,
|
|
}),
|
|
defaultOptions: {
|
|
queries: {
|
|
// Retrying an expired session only delays the trip to the login screen.
|
|
retry: (count, error) => !isPointlessToRetry(error) && count < 3,
|
|
},
|
|
mutations: {
|
|
retry: false,
|
|
},
|
|
},
|
|
})
|
|
|
|
const router = createRouter({
|
|
routeTree,
|
|
basepath: portal?.basePath,
|
|
// Routes are code-split, so going somewhere new fetches a chunk first. Under
|
|
// the threshold that arrives faster than a spinner would be worth looking at.
|
|
defaultPendingComponent: PageLoading,
|
|
defaultPendingMs: 250,
|
|
})
|
|
declare module "@tanstack/react-router" {
|
|
interface Register {
|
|
router: typeof router
|
|
}
|
|
}
|
|
|
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
|
<StrictMode>
|
|
<ThemeProvider defaultTheme="system" storageKey="fluksio-ui-theme">
|
|
<MotionConfig reducedMotion="user">
|
|
<QueryClientProvider client={queryClient}>
|
|
<RouterProvider router={router} />
|
|
<Toaster richColors closeButton />
|
|
</QueryClientProvider>
|
|
</MotionConfig>
|
|
</ThemeProvider>
|
|
</StrictMode>,
|
|
)
|