singularity-forge/web/lib/dev-overrides.tsx
2026-05-05 14:46:18 +02:00

172 lines
5.5 KiB
TypeScript

"use client";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { authFetch } from "@/lib/auth";
// ─── Dev mode detection ─────────────────────────────────────────────
/**
* Build-time hint — may be `false` even in source-dev if the build happened
* without the env var. The runtime check via `/api/dev-mode` is authoritative.
*/
const BUILD_TIME_HINT = process.env.NEXT_PUBLIC_SF_DEV === "1";
/**
* Exported for static guards that run before the runtime check resolves.
* Defaults to the build-time hint, then gets upgraded by the API response.
* Components that need the authoritative value should use `useDevOverrides().isDevMode`.
*/
export let IS_DEV_MODE = BUILD_TIME_HINT;
// ─── Override keys ──────────────────────────────────────────────────
/**
* Each override is a named boolean toggle.
* Add new overrides here — they automatically appear in the Admin panel.
*/
export interface DevOverrideMap {
/** Force the onboarding wizard to render regardless of auth state. */
forceOnboarding: boolean;
}
export type DevOverrideKey = keyof DevOverrideMap;
export interface DevOverrideEntry {
key: DevOverrideKey;
label: string;
description: string;
/** Keyboard shortcut label shown in the UI, e.g. "Ctrl+Shift+1" */
shortcutLabel: string;
}
/** Registry of all available overrides, their labels, and shortcuts. */
export const DEV_OVERRIDE_REGISTRY: DevOverrideEntry[] = [
{
key: "forceOnboarding",
label: "Onboarding wizard",
description:
"Force the onboarding gate to render even when credentials are valid",
shortcutLabel: "Ctrl+Shift+1",
},
];
// ─── Default state ──────────────────────────────────────────────────
const DEFAULT_OVERRIDES: DevOverrideMap = {
forceOnboarding: false,
};
// ─── Context ────────────────────────────────────────────────────────
interface DevOverridesContextValue {
/** Whether the host is source-dev (runtime-checked via /api/dev-mode). */
isDevMode: boolean;
/** Whether dev-mode overrides are globally enabled (the master toggle). */
enabled: boolean;
setEnabled: (enabled: boolean) => void;
/** Individual override values. Only effective when `enabled` is true. */
overrides: DevOverrideMap;
/** Toggle an individual override. */
toggle: (key: DevOverrideKey) => void;
/** Resolve an override: returns true only when master + individual are both on. */
isActive: (key: DevOverrideKey) => boolean;
}
const DevOverridesContext = createContext<DevOverridesContextValue | null>(
null,
);
// ─── Provider ───────────────────────────────────────────────────────
export function DevOverridesProvider({ children }: { children: ReactNode }) {
const [enabled, setEnabled] = useState(false);
const [overrides, setOverrides] = useState<DevOverrideMap>(DEFAULT_OVERRIDES);
const [isDevMode, setIsDevMode] = useState(BUILD_TIME_HINT);
// Fetch authoritative dev-mode flag from the server at mount
useEffect(() => {
authFetch("/api/dev-mode", { cache: "no-store" })
.then((res) => res.json())
.then((data: { isDevMode?: boolean }) => {
if (data.isDevMode) {
setIsDevMode(true);
IS_DEV_MODE = true;
}
})
.catch(() => {
// Non-critical — fall back to build-time hint
});
}, []);
const toggle = useCallback((key: DevOverrideKey) => {
setOverrides((prev) => ({ ...prev, [key]: !prev[key] }));
}, []);
const isActive = useCallback(
(key: DevOverrideKey) => enabled && overrides[key],
[enabled, overrides],
);
// ─── Global keyboard shortcuts ──────────────────────────────────
useEffect(() => {
if (!isDevMode) return;
function handleKeyDown(e: KeyboardEvent) {
// Only fire when master toggle is on
if (!enabled) return;
// Ctrl+Shift+1 → toggle forceOnboarding
if (e.ctrlKey && e.shiftKey && e.key === "1") {
e.preventDefault();
setOverrides((prev) => ({
...prev,
forceOnboarding: !prev.forceOnboarding,
}));
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [enabled, isDevMode]);
const value = useMemo<DevOverridesContextValue>(
() =>
isDevMode
? { isDevMode, enabled, setEnabled, overrides, toggle, isActive }
: {
isDevMode: false,
enabled: false,
setEnabled: () => {},
overrides: DEFAULT_OVERRIDES,
toggle: () => {},
isActive: () => false,
},
[isDevMode, enabled, overrides, toggle, isActive],
);
return (
<DevOverridesContext.Provider value={value}>
{children}
</DevOverridesContext.Provider>
);
}
// ─── Hook ───────────────────────────────────────────────────────────
export function useDevOverrides(): DevOverridesContextValue {
const ctx = useContext(DevOverridesContext);
if (!ctx) {
throw new Error(
"useDevOverrides must be used within <DevOverridesProvider>",
);
}
return ctx;
}