337dda1e97
Adds a shared `pageMeta()` helper and 74 route declarations, so a title reads `run_abc | Runs | Trigger.dev` — the specific thing first, then the page. Org pages also carry the organization: `Team | Acme | Trigger.dev`. Inside a project no scope is added, because the dashboard switches projects in every tab at once. Page names are unchanged; what's new is that a page says which one it is at all. Three wording changes on purpose: the queue page now names the queue, the model page names the model, and entity pages carry their section.
223 lines
8.1 KiB
TypeScript
223 lines
8.1 KiB
TypeScript
import type { LinksFunction, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
|
|
import type { CSSProperties } from "react";
|
|
import type { ShouldRevalidateFunction } from "@remix-run/react";
|
|
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
|
|
import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson";
|
|
import { ExternalScripts } from "remix-utils/external-scripts";
|
|
import type { ToastMessage } from "~/models/message.server";
|
|
import { commitSession, getSession } from "~/models/message.server";
|
|
// Fonts imported here so Vite rebases the urls and emits the woff2 assets
|
|
import "non.geist";
|
|
import "non.geist/mono";
|
|
import tailwindStylesheetUrl from "~/tailwind.css?url";
|
|
import { RouteErrorDisplay } from "./components/ErrorDisplay";
|
|
import { GlobalShortcuts } from "./components/GlobalShortcuts";
|
|
import { StaleAssetRecovery } from "./components/StaleAssetRecovery";
|
|
import { AppContainer, MainCenteredContainer } from "./components/layout/AppLayout";
|
|
import { ShortcutsProvider } from "./components/primitives/ShortcutsProvider";
|
|
import { Toast } from "./components/primitives/Toast";
|
|
import { TimezoneSetter } from "./components/TimezoneSetter";
|
|
import { env } from "./env.server";
|
|
import { featuresForRequest } from "./features.server";
|
|
import { usePostHog } from "./hooks/usePostHog";
|
|
import { useSystemThemeSync } from "./hooks/useSystemThemeSync";
|
|
import { getImpersonationState } from "./services/impersonation.server";
|
|
import { getUser } from "./services/session.server";
|
|
import {
|
|
normalizeThemeContrast,
|
|
normalizeThemePreference,
|
|
type ThemePreference,
|
|
} from "~/utils/themePreference";
|
|
import { cachedFlag } from "~/v3/featureFlags.server";
|
|
import { getTimezonePreference } from "./services/preferences/uiPreferences.server";
|
|
import { appTitle } from "./utils/pageTitle";
|
|
|
|
// Derived here (not inside StaleAssetRecovery) so the shared component takes
|
|
// the flag as a prop. NODE_ENV is statically replaced in browser bundles, and
|
|
// the ErrorBoundary can't rely on loader data.
|
|
const isProduction = process.env.NODE_ENV === "production";
|
|
|
|
export const links: LinksFunction = () => {
|
|
return [{ rel: "stylesheet", href: tailwindStylesheetUrl }];
|
|
};
|
|
|
|
export const headers = () => ({
|
|
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Permissions-Policy":
|
|
"geolocation=(), microphone=(), camera=(), accelerometer=(), gyroscope=(), magnetometer=(), payment=(), usb=()",
|
|
});
|
|
|
|
export const meta: MetaFunction = ({ data }) => {
|
|
const typedData = data as UseDataFunctionReturn<typeof loader>;
|
|
return [
|
|
// Pages declare their own title with `pageMeta`; this is the fallback.
|
|
{ title: appTitle(typedData?.appEnv) },
|
|
{
|
|
name: "viewport",
|
|
content: "width=1024, initial-scale=1",
|
|
},
|
|
{
|
|
name: "robots",
|
|
content:
|
|
typeof window === "undefined" || window.location.hostname !== "cloud.trigger.dev"
|
|
? "noindex, nofollow"
|
|
: "index, follow",
|
|
},
|
|
];
|
|
};
|
|
|
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|
const session = await getSession(request.headers.get("cookie"));
|
|
const toastMessage = session.get("toastMessage") as ToastMessage;
|
|
const posthogProjectKey = env.POSTHOG_PROJECT_KEY;
|
|
const posthogUiHost = env.POSTHOG_HOST;
|
|
const features = featuresForRequest(request);
|
|
const timezone = await getTimezonePreference(request);
|
|
|
|
const kapa = {
|
|
websiteId: env.KAPA_AI_WEBSITE_ID,
|
|
};
|
|
|
|
const user = await getUser(request);
|
|
// Theme switching is feature-flagged; while off, everyone stays on the
|
|
// classic theme even if a preference was saved earlier. Admins always get
|
|
// the switcher so the team can dogfood before the flag flips. Cached: the
|
|
// root loader runs on every document request and client navigation.
|
|
const showThemeSwitcher = user
|
|
? user.admin || (await cachedFlag({ key: "hasThemeSwitcher", defaultValue: false }))
|
|
: false;
|
|
// Logged-out pages (login, invites) always render the branded Classic look.
|
|
const themePreference: ThemePreference = showThemeSwitcher
|
|
? normalizeThemePreference(user?.dashboardPreferences.theme)
|
|
: "classic";
|
|
const themeContrast = showThemeSwitcher
|
|
? normalizeThemeContrast(user?.dashboardPreferences.contrast)
|
|
: 0;
|
|
// Display-only: while impersonating, an admin can ask to see the dashboard
|
|
// the way the impersonated user sees it. Exposed from root so every route can
|
|
// read it.
|
|
//
|
|
// Resolved against the user this request authenticated as, which is the same
|
|
// condition `requireUser` applies — otherwise the flag the client reads and
|
|
// the `user.isViewingAsUser` the server computes could disagree, and the
|
|
// client-side admin UI would hide itself on a session that is not
|
|
// impersonating.
|
|
const { isViewingAsUser } = await getImpersonationState(request, user?.id);
|
|
|
|
const headers = new Headers();
|
|
headers.append("Set-Cookie", await commitSession(session));
|
|
|
|
return typedjson(
|
|
{
|
|
user,
|
|
isViewingAsUser,
|
|
toastMessage,
|
|
posthogProjectKey,
|
|
posthogUiHost,
|
|
features,
|
|
appEnv: env.APP_ENV,
|
|
appOrigin: env.APP_ORIGIN,
|
|
apiOrigin: env.API_ORIGIN ?? env.APP_ORIGIN,
|
|
triggerCliTag: env.TRIGGER_CLI_TAG,
|
|
kapa,
|
|
timezone,
|
|
showThemeSwitcher,
|
|
themePreference,
|
|
themeContrast,
|
|
// Consumed by ResizablePanel: the browser check must match between SSR
|
|
// and hydration, so it is derived from the request user-agent.
|
|
isFirefox: /firefox/i.test(request.headers.get("user-agent") ?? ""),
|
|
},
|
|
{ headers }
|
|
);
|
|
};
|
|
|
|
export type LoaderType = typeof loader;
|
|
|
|
export const shouldRevalidate: ShouldRevalidateFunction = (options) => {
|
|
if (options.formAction === "/resources/environment") {
|
|
return false;
|
|
}
|
|
|
|
return options.defaultShouldRevalidate;
|
|
};
|
|
|
|
export function ErrorBoundary() {
|
|
return (
|
|
<>
|
|
<html lang="en" className="h-full" data-theme="classic">
|
|
<head>
|
|
<meta charSet="utf-8" />
|
|
|
|
<StaleAssetRecovery isProduction={isProduction} />
|
|
<Meta />
|
|
<Links />
|
|
</head>
|
|
<body className="h-full overflow-hidden bg-background-dimmed antialiased">
|
|
<ShortcutsProvider>
|
|
<AppContainer>
|
|
<MainCenteredContainer>
|
|
<RouteErrorDisplay />
|
|
</MainCenteredContainer>
|
|
</AppContainer>
|
|
</ShortcutsProvider>
|
|
<Scripts />
|
|
</body>
|
|
</html>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function App() {
|
|
const {
|
|
posthogProjectKey,
|
|
posthogUiHost,
|
|
kapa: _kapa,
|
|
themePreference,
|
|
themeContrast,
|
|
} = useTypedLoaderData<typeof loader>();
|
|
usePostHog(posthogProjectKey, posthogUiHost);
|
|
useSystemThemeSync(themePreference);
|
|
// SSR falls back to dark for `system`; the inline script below corrects it
|
|
// before paint, and useSystemThemeSync keeps it live afterwards.
|
|
const resolvedTheme = themePreference === "system" ? "dark" : themePreference;
|
|
|
|
return (
|
|
<>
|
|
<html
|
|
lang="en"
|
|
className="h-full"
|
|
// The pre-paint script below may flip data-theme before hydration
|
|
suppressHydrationWarning
|
|
data-theme={resolvedTheme}
|
|
data-theme-preference={themePreference}
|
|
// Contrast overlay input for the System themes; Classic never reads it
|
|
style={{ "--theme-contrast": themeContrast / 100 } as CSSProperties}
|
|
>
|
|
<head>
|
|
<script
|
|
dangerouslySetInnerHTML={{
|
|
__html: `try{if(document.documentElement.getAttribute("data-theme-preference")==="system"){document.documentElement.setAttribute("data-theme",matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light")}}catch(e){}`,
|
|
}}
|
|
/>
|
|
<StaleAssetRecovery isProduction={isProduction} />
|
|
<Meta />
|
|
<Links />
|
|
</head>
|
|
<body className="h-full overflow-hidden bg-background-dimmed antialiased">
|
|
<ShortcutsProvider>
|
|
<TimezoneSetter />
|
|
<GlobalShortcuts />
|
|
<Outlet />
|
|
<Toast />
|
|
</ShortcutsProvider>
|
|
<ScrollRestoration />
|
|
<ExternalScripts />
|
|
<Scripts />
|
|
</body>
|
|
</html>
|
|
</>
|
|
);
|
|
}
|