Add react setup

Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
gearnode
2025-01-31 21:01:21 -08:00
parent 1bcf32c3b2
commit b3dcfc6b53
18 changed files with 4936 additions and 0 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
bin/
node_modules/

View File

@@ -0,0 +1,7 @@
LIVE_RELOAD=
POSTHOG_HOST=
POSTHOG_KEY=
FARO_PUSH_URL=
HTTP_ENDPOINT="http://example.com"

2
apps/console/.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
__generated__/*.graphql linguist-generated
__generated__/*.js linguist-generated

52
apps/console/package.json Normal file
View File

@@ -0,0 +1,52 @@
{
"name": "@probo/console",
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "npx node --env-file .env.development scripts/esbuild.mjs watch",
"build": "npx node --env-file .env.production scripts/esbuild.mjs",
"relay": "npx relay-compiler --output quiet-with-errors",
"relay:watch": "npx relay-compiler --output quiet-with-errors --watch",
"lint": "eslint src --ext .ts,.tsx"
},
"dependencies": {
"posthog-js": "^1.215.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-helmet-async": "^2.0.5",
"react-relay": "^18.2.0",
"react-router": "^7.1.5",
"relay-runtime": "^18.2.0"
},
"devDependencies": {
"@babel/core": "^7.26.7",
"@babel/preset-env": "^7.26.7",
"@babel/preset-typescript": "^7.26.0",
"@deanc/esbuild-plugin-postcss": "^1.0.2",
"@probo/tsconfig": "^0.0.1",
"@tailwindcss/postcss": "^4.0.2",
"@types/babel-core": "^6.25.10",
"@types/node": "^22.13.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react-relay": "^18.2.0",
"@types/relay-runtime": "^18.2.5",
"autoprefixer": "^10.4.20",
"babel-plugin-relay": "^18.2.0",
"esbuild": "^0.24.2",
"relay-compiler": "^18.2.0",
"tailwindcss": "^4.0.2",
"typescript": "^5.7.3"
},
"relay": {
"src": "src",
"language": "typescript",
"schema": "../../pkg/api/console/v1/schema.graphql",
"noFutureProofEnums": true,
"excludes": [
"**/node_modules/**",
"**/__mocks__/**",
"**/__generated__/**"
]
}
}

View File

@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Probo Console</title>
<link rel="icon" href="/favicon.ico" />
<meta name="description" content="TODO" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/App.css" />
</head>
<body class="bg-gray-50 text-gray-900 font-sans">
<div id="root"></div>
<script type="module" src="/App.js" defer></script>
</body>
</html>

View File

@@ -0,0 +1,217 @@
import babel from "@babel/core";
import postCssPlugin from "@deanc/esbuild-plugin-postcss";
import autoprefixer from "autoprefixer";
import { execSync, spawn } from "child_process";
import * as esbuild from "esbuild";
import fs from "node:fs/promises";
import http from "node:http";
import path from "node:path";
import tailwindcss from "@tailwindcss/postcss";
async function copyRecursive(src, dest) {
await fs.mkdir(dest, { recursive: true });
const files = await fs.readdir(src);
for (const file of files) {
const srcPath = path.join(src, file);
const destPath = path.join(dest, file);
const fileInfo = await fs.lstat(srcPath);
if (fileInfo.isDirectory()) {
await copyRecursive(srcPath, destPath);
} else {
await fs.copyFile(srcPath, destPath);
}
}
}
function runRelayCompilerInWatchMode() {
const relayProcess = spawn("npm", ["run", "relay:watch"]);
relayProcess.stdout.on("data", (data) => {
console.log(`[relay] ${data}`);
});
relayProcess.stderr.on("data", (data) => {
console.error(`[relay] ${data}`);
});
relayProcess.on("error", (error) => {
console.error("Failed to start Relay compiler:", error);
});
console.log("Relay compiler started in watch mode.");
}
const envVars = Object.fromEntries(
Object.entries(process.env).map(([key, value]) => [
`process.env.${key}`,
JSON.stringify(value),
]),
);
const hotReloading = {
name: "hot-reloading",
async setup(build) {
if (!process.env.LIVE_RELOAD) {
return;
}
build.initialOptions.banner = build.initialOptions.banner || {};
build.initialOptions.banner.js = `
${build.initialOptions.banner.js || ""};
new EventSource('/esbuild').addEventListener('change', e => {
const { added, removed, updated } = JSON.parse(e.data);
if (!added.length && !removed.length && updated.length === 1) {
for (const link of document.getElementsByTagName("link")) {
const url = new URL(link.href);
if (url.host === location.host && url.pathname === updated[0]) {
const next = link.cloneNode();
next.href = updated[0] + '?' + Math.random().toString(36).slice(2);
next.onload = () => link.remove();
link.parentNode.insertBefore(next, link.nextSibling);
return;
}
}
}
location.reload();
});
`;
return;
},
};
const relayPlugin = {
name: "relay-compiler",
setup(build) {
build.initialOptions.loader = {
...build.initialOptions.loader,
".graphql": "text",
};
build.onLoad({ filter: /\.(ts|tsx)$/ }, async (args) => {
const source = await fs.readFile(args.path, "utf8");
const result = await babel.transformAsync(source, {
filename: args.path,
presets: [
[
"@babel/preset-env",
{
modules: false,
},
],
"@babel/preset-typescript",
],
plugins: ["relay"],
sourceMaps: true,
});
return {
contents: result.code,
loader: "default",
};
});
},
};
const defaultOptions = {
logLevel: "info",
entryPoints: ["src/App.tsx"],
bundle: true,
minify: true,
publicPath: "/",
outdir: "dist",
assetNames: "assets/[name]-[hash]",
chunkNames: "chunks/[name]-[hash]",
entryNames: "[dir]/[name]",
splitting: true,
format: "esm",
sourcemap: "external",
loader: {
".png": "file",
".jpg": "file",
".jpeg": "file",
".svg": "file",
".gif": "file",
},
plugins: [
relayPlugin,
hotReloading,
postCssPlugin({
plugins: [tailwindcss(), autoprefixer()],
}),
],
define: {
...envVars,
},
};
await copyRecursive("public", "dist");
const [, , command] = process.argv;
if (command === "watch") {
runRelayCompilerInWatchMode();
const ctx = await esbuild.context({ ...defaultOptions, minify: false });
await ctx.watch();
let { host, port } = await ctx.serve({ servedir: "dist" });
http
.createServer((req, res) => {
const options = {
hostname: host,
port: port,
path: req.url,
method: req.method,
headers: req.headers,
};
const extensionPattern = /\.[^\/]+$/;
const proxyReq = http.request(options, (proxyRes) => {
if (proxyRes.statusCode === 404 && extensionPattern.test(req.url)) {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("404 - Not Found");
return;
}
if (proxyRes.statusCode === 404) {
const indexPath = path.join(
import.meta.dirname,
"../dist",
"index.html",
);
fs.readFile(indexPath, "utf8")
.then((data) => {
res.writeHead(200, { "Content-Type": "text/html" });
res.end(data);
})
.catch((_err) => {
res.writeHead(500, { "Content-Type": "text/plain" });
res.end("500 - Internal Server Error");
});
proxyRes.resume();
return;
}
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res, { end: true });
});
req.pipe(proxyReq, { end: true });
})
.listen(3000, () => {
console.log(`listening on http://localhost:3000`);
});
} else {
execSync("npm run relay", { stdio: "inherit" });
await esbuild.build(defaultOptions);
}

3
apps/console/src/App.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

57
apps/console/src/App.tsx Normal file
View File

@@ -0,0 +1,57 @@
import posthog from "posthog-js";
import { PostHogProvider } from "posthog-js/react";
import { lazy, StrictMode, Suspense } from "react";
import { createRoot } from "react-dom/client";
import { HelmetProvider } from "react-helmet-async";
import { RelayEnvironmentProvider } from "react-relay";
import {
BrowserRouter,
Route,
Routes,
} from "react-router";
import "App.css";
import ErrorBoundary from "./components/ErrorBoundary";
import AuthenticateLayout from "./layouts/AuthenticateLayout";
import { RelayEnvironment } from "./RelayEnvironment";
posthog.init(process.env.POSTHOG_KEY!, {
api_host: process.env.POSTHOG_HOST,
session_recording: {
maskAllInputs: true,
},
loaded: (posthog) => {
if (!process.env.POSTHOG_KEY) posthog.debug();
},
});
const HomePage = lazy(() => import("./pages/HomePage"));
const NotFoundPage = lazy(() => import("./pages/NotFoundPage"));
function App() {
return (
<StrictMode>
<ErrorBoundary fallback={<p>Something went wrong</p>}>
<PostHogProvider client={posthog}>
<RelayEnvironmentProvider environment={RelayEnvironment}>
<HelmetProvider>
<BrowserRouter>
<Suspense>
<Routes>
<Route path="/" element={<AuthenticateLayout />}>
<Route index element={<HomePage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
</BrowserRouter>
</HelmetProvider>
</RelayEnvironmentProvider>
</PostHogProvider>
</ErrorBoundary>
</StrictMode>
);
}
const container = document.getElementById("root");
const root = createRoot(container!);
root.render(<App />);

View File

@@ -0,0 +1,48 @@
import {
Environment,
FetchFunction,
Network,
RecordSource,
Store,
} from "relay-runtime";
import { buildEndpoint } from "./utils";
const fetchRelay: FetchFunction = async (request, variables) => {
const response = await fetch(buildEndpoint("/console/v1/query"), {
method: "POST",
credentials: "include",
headers: {
Accept:
"application/graphql-response+json; charset=utf-8, application/json; charset=utf-8",
"Content-Type": "application/json",
},
body: JSON.stringify({
operationName: request.name,
query: request.text,
variables,
}),
});
const json = await response.json();
if (Array.isArray(json.errors)) {
throw new Error(
`Error fetching GraphQL query '${
request.name
}' with variables '${JSON.stringify(variables)}': ${JSON.stringify(
json.errors,
)}`,
);
}
return json;
};
function createRelayEnvironment() {
return new Environment({
network: Network.create(fetchRelay),
store: new Store(new RecordSource()),
});
}
export const RelayEnvironment: Environment = createRelayEnvironment();

View File

@@ -0,0 +1,37 @@
import { Component, ErrorInfo, ReactNode } from "react";
interface ErrorBoundaryProps {
children: ReactNode;
fallback: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | undefined;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: undefined };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error: error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error("ErrorBoundary caught an error:", error);
console.error("Error info:", info.componentStack);
}
render(): ReactNode {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -0,0 +1,9 @@
import { Outlet } from "react-router";
export default function AuthenticateLayout() {
return (
<div>
<Outlet />
</div>
)
}

View File

@@ -0,0 +1,57 @@
import { Suspense, useEffect } from "react";
import {
graphql,
PreloadedQuery,
usePreloadedQuery,
useQueryLoader,
} from "react-relay";
import { Link } from "react-router";
import type { HomePageQuery as HomePageQueryType } from "./__generated__/HomePageQuery.graphql";
export const HomePageQuery = graphql`
query HomePageQuery {
node(id: "AZSfP_xAcAC5IAAAAAAltA") {
id
... on Organization {
name
}
}
}
`;
export default function HomePage() {
const [queryRef, loadQuery] =
useQueryLoader<HomePageQueryType>(HomePageQuery);
useEffect(() => loadQuery({}), [loadQuery]);
if (!queryRef) {
return <HomePageFallback />;
}
return (
<Suspense fallback={<HomePageFallback />}>
<HomePageContent queryRef={queryRef} />
</Suspense>
);
}
function HomePageFallback() {
return <div>Loading...</div>;
}
function HomePageContent({
queryRef,
}: {
queryRef: PreloadedQuery<HomePageQueryType>;
}) {
const data = usePreloadedQuery(HomePageQuery, queryRef);
return (
<div>
<h1>User</h1>
<pre>{JSON.stringify(data, null, 2)}</pre>
<Link to="/foobar">Go to FooPage</Link>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { Link } from "react-router";
export default function NotFoundPage() {
return (
<div>
<p>The page might have been moved, deleted, or never existed.</p>
<Link to="/">Return to Home</Link>
</div>
);
}

View File

@@ -0,0 +1,118 @@
/**
* @generated SignedSource<<269bb5911dc11e2bda71f8c506e1d7e7>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type HomePageQuery$variables = Record<PropertyKey, never>;
export type HomePageQuery$data = {
readonly node: {
readonly id: string;
readonly name?: string;
};
};
export type HomePageQuery = {
response: HomePageQuery$data;
variables: HomePageQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"kind": "Literal",
"name": "id",
"value": "AZSfP_xAcAC5IAAAAAAltA"
}
],
v1 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v2 = {
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
}
],
"type": "Organization",
"abstractKey": null
};
return {
"fragment": {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "HomePageQuery",
"selections": [
{
"alias": null,
"args": (v0/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v1/*: any*/),
(v2/*: any*/)
],
"storageKey": "node(id:\"AZSfP_xAcAC5IAAAAAAltA\")"
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [],
"kind": "Operation",
"name": "HomePageQuery",
"selections": [
{
"alias": null,
"args": (v0/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
(v1/*: any*/),
(v2/*: any*/)
],
"storageKey": "node(id:\"AZSfP_xAcAC5IAAAAAAltA\")"
}
]
},
"params": {
"cacheID": "5d18865912a8a8c16bc28f5506766d82",
"id": null,
"metadata": {},
"name": "HomePageQuery",
"operationKind": "query",
"text": "query HomePageQuery {\n node(id: \"AZSfP_xAcAC5IAAAAAAltA\") {\n __typename\n id\n ... on Organization {\n name\n }\n }\n}\n"
}
};
})();
(node as any).hash = "0da7306fdc31722644ce24a893020fae";
export default node;

14
apps/console/src/utils.ts Normal file
View File

@@ -0,0 +1,14 @@
export function buildEndpoint(path: string): string {
const host = process.env.API_SERVER_HOST!;
const formattedHost =
host.startsWith("http://") || host.startsWith("https://")
? host
: `https://${host}`;
const url = new URL(formattedHost);
if (path) {
url.pathname = path.startsWith("/") ? path : `/${path}`;
}
return url.toString();
}

View File

@@ -0,0 +1,9 @@
import type { Config } from "tailwindcss";
export default {
content: ["src/**/*.tsx"],
theme: {
extend: {},
},
plugins: [],
} satisfies Config;

View File

@@ -0,0 +1,6 @@
{
"extends": "@probo/tsconfig/react.json",
"include": ["src"],
"exclude": ["node_modules"]
}

4266
package-lock.json generated

File diff suppressed because it is too large Load Diff