diff --git a/env/.local.env b/.env
similarity index 81%
rename from env/.local.env
rename to .env
index 84f87610c..3e76ccab6 100644
--- a/env/.local.env
+++ b/.env
@@ -1,11 +1,13 @@
-# Environment variables for the local development and testing
+# Environment variables
+# https://vitejs.dev/guide/env-and-mode.html#env-files
#
-# NOTE: You can place secrets and local overrides into the
-# ".local.override.env" file which is excluded from this Git repo
+# TIP: Feel free to personalize these settings in your `.env.local` file that
+# is not tracked by source control, giving you the liberty to tweak
+# settings in your local environment worry-free! Happy coding! π
# Web application settings
APP_ENV=local
-APP_NAME=React App
+APP_NAME=Acme Co.
APP_HOSTNAME=localhost
APP_ORIGIN=http://localhost:5173
API_ORIGIN=https://api-mcfytwakla-uc.a.run.app
diff --git a/.gitignore b/.gitignore
index 952ea4857..4e6a35f8d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,7 +28,8 @@ yarn-error.log*
*.lcov
# Environment variables
-*.override.env
+.env.*.local
+.env.local
# Visual Studio Code
# https://github.com/github/gitignore/blob/master/Global/VisualStudioCode.gitignore
diff --git a/.vscode/react.code-snippets b/.vscode/react.code-snippets
index dba6db51a..1faeeb5ab 100644
--- a/.vscode/react.code-snippets
+++ b/.vscode/react.code-snippets
@@ -3,28 +3,34 @@
"scope": "javascriptreact,typescriptreact",
"prefix": "react",
"body": [
- "import { ${1:import}, ${1:import}Props } from \"@mui/material\";",
- "import * as React from \"react\";",
+ "import { ${2:Box}, ${2}Props } from \"@mui/joy\";",
"",
- "function ${TM_FILENAME_BASE}(props: ${TM_FILENAME_BASE}Props): JSX.Element {",
- " const { ...other } = props;",
+ "export function ${1:${TM_FILENAME_BASE/(^|-)(.)/${2:/upcase}/g}}(props: ${1}Props): JSX.Element {",
+ " const { sx, ...other } = props;",
"",
" return (",
- " <${1:import} {...other}>$0${1:import}>",
+ " <${2} sx={{ ...sx }} {...other}>$0${2}>",
" );",
"}",
"",
- "type ${TM_FILENAME_BASE}Props = Omit<${1:import}Props, \"children\">;",
- "",
- "export { ${TM_FILENAME_BASE} };",
+ "export type ${1}Props = Omit<${2}Props, \"children\">;",
""
],
"description": "React Component"
},
- "Import": {
+ "ReactRoute": {
"scope": "javascriptreact,typescriptreact",
- "prefix": "imp",
- "body": ["import { ${0} } from \"${1:@material-ui/icons}\";"],
- "description": "Import"
+ "prefix": "route",
+ "body": [
+ "import { ${2:Box}, ${2}Props } from \"@mui/joy\";",
+ "",
+ "export const Component = function ${1:${TM_FILENAME_BASE/(^|-)(.)/${2:/upcase}/g}}(): JSX.Element {",
+ " return (",
+ " <${2}>$0${2}>",
+ " );",
+ "}",
+ ""
+ ],
+ "description": "React Route Component"
}
}
diff --git a/.vscode/settings.json b/.vscode/settings.json
index bf307fac3..a73d7b43f 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -11,6 +11,10 @@
"typescript.tsdk": ".yarn/sdks/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"vitest.commandLine": "yarn vitest",
+ "files.associations": {
+ ".env.*.local": "properties",
+ ".env.*": "properties"
+ },
"files.exclude": {
"**/.cache": true,
"**/.DS_Store": true,
@@ -44,6 +48,7 @@
"browserslist",
"cloudfunctions",
"corejs",
+ "corepack",
"endregion",
"entrypoint",
"envalid",
diff --git a/README.md b/README.md
index 35a2c65c2..0e53aa73a 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ Be sure to join our [Discord channel](https://discord.com/invite/2nKEnKq) for as
`βββ`[`.github`](.github) β GitHub configuration including CI/CD workflows
`βββ`[`.vscode`](.vscode) β VSCode settings including code snippets, recommended extensions etc.
-`βββ`[`app`](./app) β Web application front-end built with [React](https://react.dev/) and [Material UI](https://mui.com/core/)
+`βββ`[`app`](./app) β Web application front-end built with [React](https://react.dev/) and [Joy UI](https://mui.com/joy-ui/getting-started/)
`βββ`[`edge`](./edge) β Cloudflare Workers (CDN) edge endpoint
`βββ`[`env`](./env) β Application settings, API keys, etc.
`βββ`[`scripts`](./scripts) β Automation scripts such as `yarn deploy`
@@ -39,7 +39,7 @@ Be sure to join our [Discord channel](https://discord.com/invite/2nKEnKq) for as
## Tech Stack
-- [React](https://react.dev/), [React Router](https://reactrouter.com/), [Recoil](https://recoiljs.org/), [Emotion](https://emotion.sh/), [Material UI](https://next.material-ui.com/), [Firebase Authentication](https://firebase.google.com/docs/auth)
+- [React](https://react.dev/), [React Router](https://reactrouter.com/), [Jotai](https://jotai.org/), [Emotion](https://emotion.sh/), [Joy UI](https://mui.com/joy-ui/getting-started/), [Firebase Authentication](https://firebase.google.com/docs/auth)
- [Cloudflare Workers](https://workers.cloudflare.com/), [Vite](https://vitejs.dev/), [Vitest](https://vitejs.dev/),
[TypeScript](https://www.typescriptlang.org/), [ESLint](https://eslint.org/), [Prettier](https://prettier.io/), [Yarn](https://yarnpkg.com/) with PnP
@@ -59,11 +59,12 @@ environment variables found in [`env/*.env`](./env/), and start hacking:
```
$ git clone https://github.com/kriasoft/react-starter-kit.git example
$ cd ./example
+$ corepack enable
$ yarn install
-$ yarn start
+$ yarn workspace app start
```
-The app will become available at [http://localhost:5173/](http://localhost:5173/) (press `q` key to exit).
+The app will become available at [http://localhost:5173/](http://localhost:5173/) (press `q` + `Enter` to exit).
**IMPORTANT**: Ensure that VSCode is using the workspace [version of TypeScript](https://code.visualstudio.com/docs/typescript/typescript-compiling#_using-newer-typescript-versions)
and ESLint.
diff --git a/app/README.md b/app/README.md
index 8df284953..47590bdda 100644
--- a/app/README.md
+++ b/app/README.md
@@ -2,14 +2,11 @@
## Directory Structure
-`βββ`[`common`](./common) β Common (shared) React components
+`βββ`[`components`](./components) β UI elements
`βββ`[`core`](./core) β Core modules, React hooks, customized theme, etc.
-`βββ`[`dialogs`](./dialogs) β React components implementing modal dialogs
`βββ`[`icons`](./icons) β Custom icon React components
-`βββ`[`layout`](./layout) β Layout related components
`βββ`[`public`](./public) β Static assets such as robots.txt, index.html etc.
`βββ`[`routes`](./routes) β Application routes and page (screen) components
-`βββ`[`theme`](./theme) β Customized Material UI theme
`βββ`[`global.d.ts`](./global.d.ts) β Global TypeScript declarations
`βββ`[`index.html`](./index.html) β HTML page containing application entry point
`βββ`[`index.tsx`](./index.tsx) β Single-page application (SPA) entry point
@@ -35,7 +32,7 @@ $ yarn workspace app start
## References
- https://react.dev/ β React.js documentation
-- https://mui.com/core/ β Material UI library documentation
+- https://mui.com/joy-ui/getting-started/ β Joy UI documentation
- https://www.typescriptlang.org/ β TypeScript reference
- https://vitejs.dev/ β Front-end tooling (bundler)
- https://vitest.dev/ β Unit test framework
diff --git a/app/common/Link.tsx b/app/common/Link.tsx
deleted file mode 100644
index 875004774..000000000
--- a/app/common/Link.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-/* SPDX-FileCopyrightText: 2014-present Kriasoft */
-/* SPDX-License-Identifier: MIT */
-
-import * as React from "react";
-import {
- Link as RouterLink,
- LinkProps as RouterLinkProps,
-} from "react-router-dom";
-
-export const Link = React.forwardRef(
- function Link(props, ref): JSX.Element {
- const { href, ...other } = props;
- return ;
- },
-);
-
-export type LinkProps = Omit & {
- href: RouterLinkProps["to"];
-};
diff --git a/app/components/button-color-scheme.tsx b/app/components/button-color-scheme.tsx
new file mode 100644
index 000000000..4a60f0358
--- /dev/null
+++ b/app/components/button-color-scheme.tsx
@@ -0,0 +1,72 @@
+/* SPDX-FileCopyrightText: 2014-present Kriasoft */
+/* SPDX-License-Identifier: MIT */
+
+import { DarkModeRounded, LightModeRounded } from "@mui/icons-material";
+import {
+ Dropdown,
+ IconButton,
+ IconButtonProps,
+ ListItemContent,
+ ListItemDecorator,
+ Menu,
+ MenuButton,
+ MenuItem,
+ useColorScheme,
+} from "@mui/joy";
+import { memo } from "react";
+
+export function ColorSchemeButton(props: ColorSchemeButtonProps): JSX.Element {
+ const { mode, systemMode } = useColorScheme();
+
+ return (
+
+
+ {mode === "light" || (mode === "system" && systemMode === "light") ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
+
+const ModeMenuItem = memo(function ModeMenuItem({
+ mode,
+}: ModeMenuItemProps): JSX.Element {
+ const scheme = useColorScheme();
+
+ return (
+
+ );
+});
+
+type ColorSchemeButtonProps = Omit;
+type ModeMenuItemProps = { mode: "dark" | "light" | "system" };
diff --git a/app/components/button-login.tsx b/app/components/button-login.tsx
new file mode 100644
index 000000000..1b7334801
--- /dev/null
+++ b/app/components/button-login.tsx
@@ -0,0 +1,45 @@
+/* SPDX-FileCopyrightText: 2014-present Kriasoft */
+/* SPDX-License-Identifier: MIT */
+
+import { Button, ButtonProps } from "@mui/joy";
+import { SignInMethod, useSignIn } from "../core/auth";
+import { AnonymousIcon, GoogleIcon } from "../icons";
+
+export function LoginButton(props: LoginButtonProps): JSX.Element {
+ const { signInMethod, ...other } = props;
+ const [signIn, inFlight] = useSignIn(signInMethod);
+
+ const icon =
+ signInMethod === "google.com" ? (
+
+ ) : signInMethod === "anonymous" ? (
+
+ ) : null;
+
+ return (
+
+ );
+}
+
+export type LoginButtonProps = Omit<
+ ButtonProps<
+ "button",
+ {
+ signInMethod: SignInMethod;
+ }
+ >,
+ "children"
+>;
diff --git a/app/components/button-user-avatar.tsx b/app/components/button-user-avatar.tsx
new file mode 100644
index 000000000..44f363554
--- /dev/null
+++ b/app/components/button-user-avatar.tsx
@@ -0,0 +1,57 @@
+/* SPDX-FileCopyrightText: 2014-present Kriasoft */
+/* SPDX-License-Identifier: MIT */
+
+import { LogoutRounded, SettingsRounded } from "@mui/icons-material";
+import {
+ Avatar,
+ Dropdown,
+ IconButton,
+ IconButtonProps,
+ ListItemContent,
+ ListItemDecorator,
+ Menu,
+ MenuButton,
+ MenuItem,
+} from "@mui/joy";
+import { getAuth, signOut } from "firebase/auth";
+import { useCurrentUser } from "../core/auth";
+
+export function UserAvatarButton(props: UserAvatarButtonProps): JSX.Element {
+ const { sx, ...other } = props;
+ const user = useCurrentUser()!;
+
+ return (
+
+
+
+ {user.displayName}
+
+
+
+
+
+ );
+}
+
+export type UserAvatarButtonProps = Omit;
diff --git a/app/layout/RootError.tsx b/app/components/error.tsx
similarity index 79%
rename from app/layout/RootError.tsx
rename to app/components/error.tsx
index 34003dbe2..faaedbe25 100644
--- a/app/layout/RootError.tsx
+++ b/app/components/error.tsx
@@ -1,7 +1,7 @@
-/* SPDX-FileCopyrightText: 2020-present Kriasoft */
+/* SPDX-FileCopyrightText: 2014-present Kriasoft */
/* SPDX-License-Identifier: MIT */
-import { Container, Typography } from "@mui/material";
+import { Container, Typography } from "@mui/joy";
import { useRouteError } from "react-router-dom";
export function RootError(): JSX.Element {
@@ -13,10 +13,10 @@ export function RootError(): JSX.Element {
sx={{
fontSize: "2em",
fontWeight: 300,
+ textAlign: "center",
"& strong": { fontWeight: 400 },
}}
- variant="h1"
- align="center"
+ level="h1"
>
Error {err.status || 500}:{" "}
{err.statusText ?? err.message}
diff --git a/app/components/index.ts b/app/components/index.ts
new file mode 100644
index 000000000..7f17207d0
--- /dev/null
+++ b/app/components/index.ts
@@ -0,0 +1,7 @@
+/* SPDX-FileCopyrightText: 2014-present Kriasoft */
+/* SPDX-License-Identifier: MIT */
+
+export * from "./button-login";
+export * from "./error";
+export * from "./layout";
+export * from "./logo";
diff --git a/app/components/layout.tsx b/app/components/layout.tsx
new file mode 100644
index 000000000..0754e7bf8
--- /dev/null
+++ b/app/components/layout.tsx
@@ -0,0 +1,69 @@
+/* SPDX-FileCopyrightText: 2014-present Kriasoft */
+/* SPDX-License-Identifier: MIT */
+
+import { Box, GlobalStyles } from "@mui/joy";
+import { Fragment, Suspense } from "react";
+import { Outlet } from "react-router-dom";
+import { Logo } from "./logo";
+import { Sidebar } from "./sidebar";
+import { Toolbar } from "./toolbar";
+
+/**
+ * The main application layout.
+ */
+export function MainLayout(): JSX.Element {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * The minimal app layout to be used on pages such Login/Signup,
+ * Privacy Policy, Terms of Use, etc.
+ */
+export function BaseLayout(): JSX.Element {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/logo.tsx b/app/components/logo.tsx
new file mode 100644
index 000000000..e15338ef1
--- /dev/null
+++ b/app/components/logo.tsx
@@ -0,0 +1,33 @@
+/* SPDX-FileCopyrightText: 2014-present Kriasoft */
+/* SPDX-License-Identifier: MIT */
+
+import { BrightnessAutoRounded } from "@mui/icons-material";
+import { Box, BoxProps, IconButton, Typography } from "@mui/joy";
+import { Link } from "react-router-dom";
+
+export function Logo(props: LogoProps): JSX.Element {
+ const { sx, ...other } = props;
+
+ return (
+
+
+
+
+
+ {import.meta.env.VITE_APP_NAME}
+
+
+ );
+}
+
+export type LogoProps = Omit;
diff --git a/app/components/navigation.tsx b/app/components/navigation.tsx
new file mode 100644
index 000000000..532a8ed26
--- /dev/null
+++ b/app/components/navigation.tsx
@@ -0,0 +1,64 @@
+/* SPDX-FileCopyrightText: 2014-present Kriasoft */
+/* SPDX-License-Identifier: MIT */
+
+import {
+ AssignmentTurnedInRounded,
+ ChatRounded,
+ Dashboard,
+} from "@mui/icons-material";
+import {
+ List,
+ ListItem,
+ ListItemButton,
+ ListItemContent,
+ ListItemDecorator,
+ ListProps,
+} from "@mui/joy";
+import { ReactNode, memo } from "react";
+import { Link, useMatch } from "react-router-dom";
+
+export const Navigation = memo(function Navigation(
+ props: NavigationProps,
+): JSX.Element {
+ const { sx, ...other } = props;
+
+ return (
+
+ } />
+ }
+ />
+ } />
+
+ );
+});
+
+function NavItem(props: NavItemProps): JSX.Element {
+ return (
+
+
+
+ {props.label}
+
+
+ );
+}
+
+type NavigationProps = Omit;
+type NavItemProps = {
+ path: string;
+ label: string;
+ icon: ReactNode;
+};
diff --git a/app/components/sidebar.tsx b/app/components/sidebar.tsx
new file mode 100644
index 000000000..92dbb1b06
--- /dev/null
+++ b/app/components/sidebar.tsx
@@ -0,0 +1,30 @@
+/* SPDX-FileCopyrightText: 2014-present Kriasoft */
+/* SPDX-License-Identifier: MIT */
+
+import { Sheet, SheetProps } from "@mui/joy";
+import { Navigation } from "./navigation";
+
+const width = 260;
+
+export function Sidebar(props: SidebarProps): JSX.Element {
+ const { sx, ...other } = props;
+
+ return (
+ `1px solid ${palette.divider}`,
+ overflow: "auto",
+ width,
+ ...sx,
+ }}
+ aria-label="Sidebar"
+ {...other}
+ >
+
+
+ );
+}
+
+export type SidebarProps = Omit;
diff --git a/app/components/toolbar.tsx b/app/components/toolbar.tsx
new file mode 100644
index 000000000..b60899cb0
--- /dev/null
+++ b/app/components/toolbar.tsx
@@ -0,0 +1,67 @@
+/* SPDX-FileCopyrightText: 2014-present Kriasoft */
+/* SPDX-License-Identifier: MIT */
+
+import { ExpandMoreRounded, NotificationsRounded } from "@mui/icons-material";
+import { Box, BoxProps, Button, IconButton } from "@mui/joy";
+import { Fragment, Suspense } from "react";
+import { Link } from "react-router-dom";
+import { useCurrentUser } from "../core/auth";
+import { ColorSchemeButton } from "./button-color-scheme";
+import { UserAvatarButton } from "./button-user-avatar";
+
+export function Toolbar(props: ToolbarProps): JSX.Element {
+ const { sx, ...other } = props;
+
+ return (
+
+ }
+ children="Project Name"
+ />
+
+
+
+
+
+
+
+ );
+}
+
+function ActionButtons(): JSX.Element {
+ const user = useCurrentUser();
+
+ return (
+
+
+
+
+
+
+
+ {user ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+type ToolbarProps = Omit, "children">;
diff --git a/app/core/auth.ts b/app/core/auth.ts
index 92ab10f66..5137a948b 100644
--- a/app/core/auth.ts
+++ b/app/core/auth.ts
@@ -1,183 +1,85 @@
/* SPDX-FileCopyrightText: 2014-present Kriasoft */
/* SPDX-License-Identifier: MIT */
-import { type User, type UserCredential } from "firebase/auth";
-import * as React from "react";
-import { atom, useRecoilValueLoadable } from "recoil";
-import { useOpenLoginDialog } from "../dialogs/LoginDialog.js";
import {
- auth,
- signIn,
- type SignInMethod,
- type SignInOptions,
-} from "./firebase.js";
-
-let idTokenPromise: Promise | undefined;
-let idTokenPromiseResolve:
- | ((value: Promise | null) => void)
- | undefined;
-
-const unsubscribeIdTokenChanged = auth.onIdTokenChanged((user) => {
- if (user) {
- idTokenPromise = user.getIdToken();
- idTokenPromiseResolve?.(idTokenPromise as Promise);
- } else {
- idTokenPromise = Promise.resolve(null);
- idTokenPromiseResolve?.(null);
- }
+ GoogleAuthProvider,
+ User,
+ UserCredential,
+ getAuth,
+ signInAnonymously,
+ signInWithPopup,
+} from "firebase/auth";
+import { atom, useAtomValue } from "jotai";
+import { atomEffect } from "jotai-effect";
+import { loadable } from "jotai/utils";
+import { useCallback, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { app } from "./firebase";
+
+export const currentUserValue = atom(undefined);
+
+export const currentUserListener = atomEffect((get, set) => {
+ return getAuth(app).onAuthStateChanged((user) => {
+ set(currentUserValue, user);
+ });
});
-if (import.meta.hot) {
- import.meta.hot.dispose(unsubscribeIdTokenChanged);
-}
+export const currentUserAsync = atom(async (get) => {
+ get(currentUserListener);
+ const user = get(currentUserValue);
-/**
- * Returns a JSON Web Token (JWT) used to identify the user. If the user is not
- * authenticated, returns `null`. If the token is expired or will expire in the
- * next five minutes, refreshes the token and returns a new one.
- */
-export async function getIdToken() {
- if (!idTokenPromise) {
- idTokenPromise = new Promise((resolve, reject) => {
- const timeout = setTimeout(() => {
- reject(new Error("getIdToken() timeout"));
- }, 5000);
-
- idTokenPromiseResolve = (value: PromiseLike | null) => {
- resolve(value);
- clearTimeout(timeout);
- idTokenPromiseResolve = undefined;
- };
- });
+ if (user === undefined) {
+ const auth = getAuth(app);
+ await auth.authStateReady();
+ return auth.currentUser;
+ } else {
+ return user;
}
-
- return await idTokenPromise;
-}
-
-export const SignInMethods: SignInMethod[] = [
- "google.com",
- "apple.com",
- "anonymous",
-];
-
-export const CurrentUser = atom({
- key: "CurrentUser",
- dangerouslyAllowMutability: true,
- effects: [
- (ctx) => {
- if (ctx.trigger === "get") {
- return auth.onAuthStateChanged((user) => {
- ctx.setSelf(user);
- });
- }
- },
- ],
});
-/**
- * The currently logged-in (authenticated) user object.
- *
- * @example
- * const { useCurrentUser } from "../core/auth.js";
- *
- * function Example(): JSX.Element {
- * const me = useCurrentUser();
- * // => { uid: "xxx", email: "me@example.com", ... }
- * // => Or, `null` when not authenticated
- * // => Or, `undefined` when not initialized
- * }
- */
-export function useCurrentUser() {
- const value = useRecoilValueLoadable(CurrentUser);
- return value.state === "loading" ? undefined : value.valueOrThrow();
-}
+export const currentUserLoadable = loadable(currentUserAsync);
-export function useSignIn() {
- const openLoginDialog = useOpenLoginDialog();
- return React.useCallback(
- async function (options?: SignInOptions) {
- if (options?.method) {
- try {
- return await signIn(options);
- } catch (err) {
- return await openLoginDialog({ error: err as Error });
- }
- } else {
- return await openLoginDialog();
- }
- },
- [openLoginDialog],
- );
+export function useCurrentUser() {
+ return useAtomValue(currentUserAsync);
}
-export function useSignOut() {
- return React.useCallback(() => auth.signOut(), []);
+export function useCurrentUserLoadable() {
+ return useAtomValue(currentUserLoadable);
}
-/**
- * Returns a memoized version of the callback that triggers opening a login
- * dialog in case the user is not authenticated.
- *
- * @example
- * const saveProfile = useAuthCallback(async (me) => {
- * await updateProfile(me, input);
- * }, [input])
- */
-export function useAuthCallback(
- callback: T,
- deps: React.DependencyList = [],
-): (...args: AuthCallbackParameters) => Promise>> {
- const openLoginDialog = useOpenLoginDialog();
- return React.useCallback(
- async (...args: AuthCallbackParameters) => {
- const fb = await import("../core/firebase.js");
-
- try {
- if (!fb.auth.currentUser) {
- await openLoginDialog();
- }
-
- if (!fb.auth.currentUser) {
- return Promise.reject(new Error("Not authenticated."));
- }
-
- return await callback(fb.auth.currentUser, ...args);
- } catch (err) {
- const code = (err as { code?: string })?.code;
-
- // https://firebase.google.com/docs/reference/js/auth
- if (code?.startsWith?.("/auth") || code === "permission-denied") {
- await openLoginDialog();
-
- if (!fb.auth.currentUser) {
- throw new Error("Not authenticated.");
- }
-
- return await callback(fb.auth.currentUser, ...args);
- } else {
- throw err;
- }
- }
- },
- /* eslint-disable-next-line react-hooks/exhaustive-deps */
- [openLoginDialog, ...deps],
- );
+export function useSignIn(
+ signInMethod: SignInMethod,
+): [signIn: () => void, inFlight: boolean] {
+ const navigate = useNavigate();
+ const [inFlight, setInFlight] = useState(false);
+
+ const signIn = useCallback(() => {
+ let p: Promise | null = null;
+
+ if (signInMethod === "anonymous") {
+ const auth = getAuth(app);
+ p = signInAnonymously(auth);
+ }
+
+ if (signInMethod === "google.com") {
+ const auth = getAuth(app);
+ const provider = new GoogleAuthProvider();
+ provider.addScope("profile");
+ provider.addScope("email");
+ provider.setCustomParameters({
+ // login_hint: ...
+ prompt: "consent",
+ });
+ p = signInWithPopup(auth, provider);
+ }
+
+ if (!p) throw new Error(`Not supported: ${signInMethod}`);
+
+ setInFlight(true);
+ p.then(() => navigate("/")).finally(() => setInFlight(false));
+ }, [signInMethod, navigate]);
+
+ return [signIn, inFlight] as const;
}
-type AuthCallbackParameters = Parameters extends [
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- infer _,
- ...infer Tail,
-]
- ? Tail
- : never;
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-type AuthCallback = (user: User, ...args: any) => any;
-
-export {
- type SignInMethod,
- type SignInOptions,
- type User,
- type UserCredential,
-};
+export type SignInMethod = "google.com" | "anonymous";
diff --git a/app/core/config.ts b/app/core/config.ts
deleted file mode 100644
index 3ecc146e5..000000000
--- a/app/core/config.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-/* SPDX-FileCopyrightText: 2014-present Kriasoft */
-/* SPDX-License-Identifier: MIT */
-
-export type EnvName = "prod" | "test" | "local";
-export type Config = {
- app: {
- env: EnvName;
- name: string;
- origin: string;
- hostname: string;
- };
- firebase: {
- projectId: string;
- appId: string;
- apiKey: string;
- authDomain: string;
- measurementId: string;
- };
-};
-
-export const configs = JSON.parse(import.meta.env.VITE_CONFIG);
-export const config: Config =
- location.hostname === configs.prod.app.hostname
- ? configs.prod
- : location.hostname === configs.test.app.hostname
- ? configs.test
- : configs.local;
diff --git a/app/core/firebase.ts b/app/core/firebase.ts
index 5decb93c7..e2c40766a 100644
--- a/app/core/firebase.ts
+++ b/app/core/firebase.ts
@@ -2,113 +2,16 @@
/* SPDX-License-Identifier: MIT */
import { getAnalytics } from "firebase/analytics";
-import { FirebaseError, initializeApp, type FirebaseApp } from "firebase/app";
-import {
- FacebookAuthProvider,
- fetchSignInMethodsForEmail,
- getAuth,
- GoogleAuthProvider,
- OAuthCredential,
- signInAnonymously,
- signInWithPopup,
- type Auth,
- type UserCredential,
-} from "firebase/auth";
-import { config } from "./config.js";
-export { AuthErrorCodes, linkWithCredential } from "firebase/auth";
-export { FirebaseError };
+import { initializeApp } from "firebase/app";
+import { getAuth } from "firebase/auth";
+
+export const app = initializeApp({
+ projectId: import.meta.env.VITE_GOOGLE_CLOUD_PROJECT,
+ appId: import.meta.env.VITE_FIREBASE_APP_ID,
+ apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
+ authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
+ measurementId: import.meta.env.VITE_GA_MEASUREMENT_ID,
+});
-export const app = initializeApp(config.firebase);
export const auth = getAuth(app);
export const analytics = getAnalytics(app);
-
-export function signIn(options: SignInOptions): Promise {
- if (options.method === GoogleAuthProvider.PROVIDER_ID) {
- // https://developers.google.com/identity/protocols/oauth2/web-server
- const provider = new GoogleAuthProvider();
- provider.addScope("profile");
- provider.addScope("email");
- provider.setCustomParameters({
- ...(options.email && { login_hint: options.email }),
- prompt: "consent",
- });
- return signInWithPopup(auth, provider);
- }
-
- // https://developers.facebook.com/docs/facebook-login/web
- // https://developers.facebook.com/docs/permissions/reference/
- if (options.method === FacebookAuthProvider.PROVIDER_ID) {
- const provider = new FacebookAuthProvider();
- provider.addScope("public_profile");
- provider.addScope("email");
- return signInWithPopup(auth, provider);
- }
-
- if (options.method === "anonymous") {
- return signInAnonymously(auth);
- }
-
- throw new Error(`Not supported: ${options.method}`);
-}
-
-export async function getExistingAccountFromError(
- error: FirebaseError | Error | unknown,
- method: SignInMethod,
-): Promise {
- if (
- !(error instanceof FirebaseError) ||
- error.code !== "auth/account-exists-with-different-credential" ||
- !error.customData?.email
- ) {
- return undefined;
- }
-
- const email = error.customData?.email as string;
- const signInMethods = (await fetchSignInMethodsForEmail(
- auth,
- email,
- )) as SignInMethod[];
-
- if (signInMethods.length === 0) {
- return undefined;
- }
-
- let credential: OAuthCredential | null = null;
-
- if (method === GoogleAuthProvider.PROVIDER_ID) {
- credential = GoogleAuthProvider.credentialFromError(error);
- }
-
- if (method === FacebookAuthProvider.PROVIDER_ID) {
- credential = FacebookAuthProvider.credentialFromError(error);
- }
-
- return credential ? { email, credential, signInMethods } : undefined;
-}
-
-// #region TypeScript declarations
-
-export type SignInMethod =
- | typeof GoogleAuthProvider.PROVIDER_ID
- | typeof FacebookAuthProvider.PROVIDER_ID
- | "apple.com"
- | "anonymous";
-
-export type SignInOptions = {
- method: SignInMethod;
- email?: string;
-};
-
-export type Firebase = {
- app: FirebaseApp;
- auth: Auth;
- signIn: typeof signIn;
-};
-
-export type ExistingAccount = {
- email: string;
- signInMethods: SignInMethod[];
- credential: OAuthCredential;
-};
-
-// #endregion
diff --git a/app/core/page.ts b/app/core/page.ts
index ec1192420..b9a53f38a 100644
--- a/app/core/page.ts
+++ b/app/core/page.ts
@@ -4,7 +4,8 @@
import { getAnalytics, logEvent } from "firebase/analytics";
import * as React from "react";
import { useLocation } from "react-router-dom";
-import { config } from "./config.js";
+
+const appName = import.meta.env.VITE_APP_NAME;
export function usePageEffect(
options?: Options,
@@ -18,10 +19,10 @@ export function usePageEffect(
document.title =
location.pathname === "/"
- ? options?.title ?? config.app.name
+ ? options?.title ?? appName
: options?.title
- ? `${options.title} - ${config.app.name}`
- : config.app.name;
+ ? `${options.title} - ${appName}`
+ : appName;
return function () {
document.title = previousTitle;
@@ -37,7 +38,7 @@ export function usePageEffect(
React.useEffect(() => {
if (!(options?.trackPageView === false)) {
logEvent(getAnalytics(), "page_view", {
- page_title: options?.title ?? config.app.name,
+ page_title: options?.title ?? appName,
page_path: `${location.pathname}${location.search}`,
});
}
diff --git a/app/core/store.ts b/app/core/store.ts
new file mode 100644
index 000000000..d143099dc
--- /dev/null
+++ b/app/core/store.ts
@@ -0,0 +1,19 @@
+/* SPDX-FileCopyrightText: 2014-present Kriasoft */
+/* SPDX-License-Identifier: MIT */
+
+import { createStore, Provider } from "jotai";
+import { createElement, ReactNode } from "react";
+
+/**
+ * Global state management powered by Jotai.
+ * @see https://jotai.org/
+ */
+export const store = createStore();
+
+export function StoreProvider(props: StoreProviderProps): JSX.Element {
+ return createElement(Provider, { store, ...props });
+}
+
+export type StoreProviderProps = {
+ children: ReactNode;
+};
diff --git a/app/core/theme.ts b/app/core/theme.ts
new file mode 100644
index 000000000..feb7080c7
--- /dev/null
+++ b/app/core/theme.ts
@@ -0,0 +1,27 @@
+/* SPDX-FileCopyrightText: 2014-present Kriasoft */
+/* SPDX-License-Identifier: MIT */
+
+import { extendTheme, ThemeProvider as Provider } from "@mui/joy/styles";
+import { createElement, ReactNode } from "react";
+
+/**
+ * Customized Joy UI theme.
+ * @see https://mui.com/joy-ui/customization/approaches/
+ */
+export const theme = extendTheme({
+ colorSchemes: {
+ light: {},
+ dark: {},
+ },
+ shadow: {},
+ typography: {},
+ components: {},
+});
+
+export function ThemeProvider(props: ThemeProviderProps): JSX.Element {
+ return createElement(Provider, { theme, ...props });
+}
+
+export type ThemeProviderProps = {
+ children: ReactNode;
+};
diff --git a/app/dialogs/LoginDialog.tsx b/app/dialogs/LoginDialog.tsx
deleted file mode 100644
index 7b1355cbb..000000000
--- a/app/dialogs/LoginDialog.tsx
+++ /dev/null
@@ -1,158 +0,0 @@
-/* SPDX-FileCopyrightText: 2014-present Kriasoft */
-/* SPDX-License-Identifier: MIT */
-
-import { Close } from "@mui/icons-material";
-import {
- Alert,
- Box,
- Dialog,
- DialogContent,
- DialogProps,
- IconButton,
- Typography,
-} from "@mui/material";
-import { type UserCredential } from "firebase/auth";
-import * as React from "react";
-import { atom, useRecoilCallback, useRecoilValue } from "recoil";
-import { SignInMethods } from "../core/auth.js";
-import { type ExistingAccount, type FirebaseError } from "../core/firebase.js";
-import {
- LoginButton,
- LoginButtonProps,
-} from "../layout/components/LoginButton.js";
-
-export const LoginDialogState = atom({
- key: "LoginDialogState",
- default: { open: false },
-});
-
-export function LoginDialog(props: LoginDialogProps): JSX.Element {
- const { error, signIn, linkTo, ...state } = useRecoilValue(LoginDialogState);
-
- const signInMethods = linkTo
- ? SignInMethods.filter((method) => linkTo.signInMethods.includes(method))
- : SignInMethods;
-
- return (
-
- );
-}
-
-export function useOpenLoginDialog() {
- return useRecoilCallback(
- (ctx) => (params?: LoginDialogProps) => {
- return new Promise((resolve) => {
- ctx.set(LoginDialogState, {
- ...params,
- open: true,
- error: params?.error,
- async onClose(event: React.MouseEvent, reason) {
- params?.onClose?.(event, reason);
- if (!event.isDefaultPrevented()) {
- ctx.set(LoginDialogState, (prev) => ({ ...prev, open: false }));
- const fb = await import("../core/firebase.js");
- throw new fb.FirebaseError(
- fb.AuthErrorCodes.USER_CANCELLED,
- "Login canceled.",
- );
- }
- },
- async signIn(event, method, linkTo) {
- event.preventDefault();
- const fb = await import("../core/firebase.js");
- try {
- const user = await fb.signIn({ method, email: linkTo?.email });
-
- if (linkTo) {
- await fb.linkWithCredential(user.user, linkTo.credential);
- }
-
- ctx.set(LoginDialogState, (prev) => ({
- ...prev,
- open: false,
- error: undefined,
- linkTo: undefined,
- }));
-
- resolve(user);
- } catch (err) {
- const linkTo = await fb.getExistingAccountFromError(err, method);
- const error = err ? (err as Error) : new Error("Login failed");
- ctx.set(LoginDialogState, (prev) => ({ ...prev, error, linkTo }));
- }
- },
- });
- });
- },
- [],
- );
-}
-
-// #region TypeScript declarations
-
-export interface LoginDialogProps
- extends Omit {
- error?: FirebaseError | Error;
- linkTo?: ExistingAccount;
-}
-
-export interface LoginDialogAtom extends LoginDialogProps {
- open: boolean;
- signIn?: LoginButtonProps["onClick"];
-}
-
-// #endregion
diff --git a/app/global.d.ts b/app/global.d.ts
index 0011f54cc..027d0ed3b 100644
--- a/app/global.d.ts
+++ b/app/global.d.ts
@@ -9,11 +9,14 @@ interface Window {
}
interface ImportMetaEnv {
- /**
- * Client-side configuration for the production, test/QA, and local
- * development environments. See `core/config.ts`, `vite.config.ts`.
- */
- readonly VITE_CONFIG: string;
+ readonly VITE_APP_ENV: string;
+ readonly VITE_APP_NAME: string;
+ readonly VITE_APP_ORIGIN: string;
+ readonly VITE_GOOGLE_CLOUD_PROJECT: string;
+ readonly VITE_FIREBASE_APP_ID: string;
+ readonly VITE_FIREBASE_API_KEY: string;
+ readonly VITE_FIREBASE_AUTH_DOMAIN: string;
+ readonly VITE_GA_MEASUREMENT_ID: string;
}
declare module "relay-runtime" {
diff --git a/app/icons/AuthIcon.tsx b/app/icons/AuthIcon.tsx
deleted file mode 100644
index 7eec41554..000000000
--- a/app/icons/AuthIcon.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-/* SPDX-FileCopyrightText: 2014-present Kriasoft */
-/* SPDX-License-Identifier: MIT */
-
-import { SvgIcon, SvgIconProps } from "@mui/material";
-import { type SignInMethod } from "../core/auth.js";
-
-export function AuthIcon(props: AuthIconProps): JSX.Element {
- const { variant, ...other } = props;
-
- if (variant === "apple.com") {
- return (
-
- Apple
-
-
- );
- }
-
- if (variant === "google.com") {
- return (
-
- Google
-
-
-
-
-
-
-
-
- );
- }
-
- if (variant === "facebook.com") {
- return (
-
- Facebook
-
-
- );
- }
-
- return (
-
- Anonymous
-
-
-
-
- );
-
- throw new TypeError(`variant: ${variant}`);
-}
-
-export type AuthIconProps = Omit<
- SvgIconProps<"svg", { variant: SignInMethod }>,
- "children" | "viewBox"
->;
diff --git a/app/icons/SocialIcon.tsx b/app/icons/SocialIcon.tsx
deleted file mode 100644
index d77a9fca1..000000000
--- a/app/icons/SocialIcon.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-/* SPDX-FileCopyrightText: 2014-present Kriasoft */
-/* SPDX-License-Identifier: MIT */
-
-import { SvgIcon, SvgIconProps } from "@mui/material";
-
-/**
- * Social icons
- * @see https://simpleicons.org/
- */
-export function SocialIcon(props: SocialIconProps): JSX.Element {
- const { variant, ...other } = props;
-
- if (variant === "twitter") {
- return (
-
- Twitter
-
-
- );
- }
-
- if (variant === "instagram") {
- return (
-
- Instagram
-
-
- );
- }
-
- if (variant === "discord") {
- return (
-
- Discord
-
-
- );
- }
-
- throw new TypeError(`variant: ${variant}`);
-}
-
-type SocialIconProps = Omit<
- SvgIconProps<"svg", { variant: "twitter" | "instagram" | "discord" }>,
- "children" | "viewBox"
->;
diff --git a/app/icons/anonymous.tsx b/app/icons/anonymous.tsx
new file mode 100644
index 000000000..392fdb108
--- /dev/null
+++ b/app/icons/anonymous.tsx
@@ -0,0 +1,45 @@
+/* SPDX-FileCopyrightText: 2014-present Kriasoft */
+/* SPDX-License-Identifier: MIT */
+
+import { SvgIcon, SvgIconProps } from "@mui/joy";
+
+export function AnonymousIcon(props: AnonymousIconProps): JSX.Element {
+ return (
+
+ Anonymous
+
+
+
+
+ );
+}
+
+export type AnonymousIconProps = Omit;
diff --git a/app/icons/apple.tsx b/app/icons/apple.tsx
new file mode 100644
index 000000000..d7f26d5ae
--- /dev/null
+++ b/app/icons/apple.tsx
@@ -0,0 +1,18 @@
+/* SPDX-FileCopyrightText: 2014-present Kriasoft */
+/* SPDX-License-Identifier: MIT */
+
+import { SvgIcon, SvgIconProps } from "@mui/joy";
+
+export function AppleIcon(props: AppleIconProps): JSX.Element {
+ return (
+
+ Apple
+
+
+ );
+}
+
+export type AppleIconProps = Omit;
diff --git a/app/icons/facebook.tsx b/app/icons/facebook.tsx
new file mode 100644
index 000000000..af6100689
--- /dev/null
+++ b/app/icons/facebook.tsx
@@ -0,0 +1,15 @@
+/* SPDX-FileCopyrightText: 2014-present Kriasoft */
+/* SPDX-License-Identifier: MIT */
+
+import { SvgIcon, SvgIconProps } from "@mui/joy";
+
+export function FacebookIcon(props: FacebookIconProps): JSX.Element {
+ return (
+
+ Facebook
+
+
+ );
+}
+
+export type FacebookIconProps = Omit;
diff --git a/app/icons/google.tsx b/app/icons/google.tsx
new file mode 100644
index 000000000..41b6b6e9b
--- /dev/null
+++ b/app/icons/google.tsx
@@ -0,0 +1,33 @@
+/* SPDX-FileCopyrightText: 2014-present Kriasoft */
+/* SPDX-License-Identifier: MIT */
+
+import { SvgIcon, SvgIconProps } from "@mui/joy";
+
+export function GoogleIcon(props: GoogleIconProps): JSX.Element {
+ return (
+
+ Google
+
+
+
+
+
+
+
+
+ );
+}
+
+export type GoogleIconProps = Omit;
diff --git a/app/icons/index.ts b/app/icons/index.ts
new file mode 100644
index 000000000..2993000ef
--- /dev/null
+++ b/app/icons/index.ts
@@ -0,0 +1,7 @@
+/* SPDX-FileCopyrightText: 2014-present Kriasoft */
+/* SPDX-License-Identifier: MIT */
+
+export * from "./anonymous";
+export * from "./apple";
+export * from "./facebook";
+export * from "./google";
diff --git a/app/index.html b/app/index.html
index e3b1b2f9e..6bf9f6378 100644
--- a/app/index.html
+++ b/app/index.html
@@ -2,7 +2,7 @@
- React App
+ %VITE_APP_NAME%
+
+
diff --git a/app/index.tsx b/app/index.tsx
index 11a3a5a88..689618a5d 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -1,28 +1,30 @@
/* SPDX-FileCopyrightText: 2014-present Kriasoft */
/* SPDX-License-Identifier: MIT */
-import { CssBaseline } from "@mui/material";
+import { CssBaseline, CssVarsProvider } from "@mui/joy";
import { SnackbarProvider } from "notistack";
-import * as React from "react";
-import * as ReactDOM from "react-dom/client";
-import { RouterProvider } from "react-router-dom";
-import { RecoilRoot } from "recoil";
-import { router } from "./routes/index.js";
-import { ThemeProvider } from "./theme/index.js";
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { StoreProvider } from "./core/store";
+import { theme } from "./core/theme";
+import { Router } from "./routes/index";
-const container = document.getElementById("root") as HTMLElement;
-const root = ReactDOM.createRoot(container);
+const container = document.getElementById("root");
+const root = createRoot(container!);
-// Render the top-level React component
root.render(
-
-
-
-
-
-
-
-
-
- ,
+
+
+
+
+
+
+
+
+
+ ,
);
+
+if (import.meta.hot) {
+ import.meta.hot.dispose(() => root.unmount());
+}
diff --git a/app/layout/AppLayout.tsx b/app/layout/AppLayout.tsx
deleted file mode 100644
index 0ccbeb5b5..000000000
--- a/app/layout/AppLayout.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-/* SPDX-FileCopyrightText: 2014-present Kriasoft */
-/* SPDX-License-Identifier: MIT */
-
-import { Toolbar } from "@mui/material";
-import * as React from "react";
-import { Outlet } from "react-router-dom";
-import { AppToolbar } from "./components/AppToolbar.js";
-
-/**
- * The primary application layout.
- */
-export function AppLayout(): JSX.Element {
- return (
-
-
-
-
-
-
-
-
- );
-}
diff --git a/app/layout/BaseLayout.tsx b/app/layout/BaseLayout.tsx
deleted file mode 100644
index 3604526f4..000000000
--- a/app/layout/BaseLayout.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-/* SPDX-FileCopyrightText: 2014-present Kriasoft */
-/* SPDX-License-Identifier: MIT */
-
-import { GlobalStyles, Toolbar } from "@mui/material";
-import * as React from "react";
-import { Outlet } from "react-router-dom";
-import { BaseToolbar } from "./components/BaseToolbar.js";
-
-/**
- * The minimal app layout to be used on pages such Login/Signup,
- * Privacy Policy, Terms of Use, etc.
- */
-export function BaseLayout(): JSX.Element {
- return (
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/app/layout/components/AppToolbar.tsx b/app/layout/components/AppToolbar.tsx
deleted file mode 100644
index 34e029091..000000000
--- a/app/layout/components/AppToolbar.tsx
+++ /dev/null
@@ -1,165 +0,0 @@
-/* SPDX-FileCopyrightText: 2014-present Kriasoft */
-/* SPDX-License-Identifier: MIT */
-
-import { ArrowDropDown, NotificationsNone } from "@mui/icons-material";
-import {
- AppBar,
- AppBarProps,
- Avatar,
- Button,
- Chip,
- IconButton,
- Link,
- Toolbar,
-} from "@mui/material";
-import * as React from "react";
-import { Link as NavLink } from "../../common/Link.js";
-import { useCurrentUser } from "../../core/auth.js";
-import { Logo } from "./Logo.js";
-import { NotificationsMenu } from "./NotificationsMenu.js";
-import { ThemeButton } from "./ThemeButton.js";
-import { UserMenu } from "./UserMenu.js";
-
-export function AppToolbar(props: AppToolbarProps): JSX.Element {
- const { sx, ...other } = props;
- const menuAnchorRef = React.createRef();
- const me = useCurrentUser();
-
- const [anchorEl, setAnchorEl] = React.useState({
- userMenu: null as HTMLElement | null,
- notifications: null as HTMLElement | null,
- });
-
- function openNotificationsMenu() {
- setAnchorEl((x) => ({ ...x, notifications: menuAnchorRef.current }));
- }
-
- function closeNotificationsMenu() {
- setAnchorEl((x) => ({ ...x, notifications: null }));
- }
-
- function openUserMenu() {
- setAnchorEl((x) => ({ ...x, userMenu: menuAnchorRef.current }));
- }
-
- function closeUserMenu() {
- setAnchorEl((x) => ({ ...x, userMenu: null }));
- }
-
- return (
- theme.zIndex.drawer + 1, ...sx }}
- color="default"
- elevation={1}
- {...other}
- >
-
- {/* App name / logo */}
-
-
-
-
-
-
-
- {/* Account related controls (icon buttons) */}
-
- {me !== undefined && }
-
- {me && (
-
- x.palette.mode === "light"
- ? x.palette.grey[300]
- : x.palette.grey[700],
- ".MuiChip-avatar": { width: 32, height: 32 },
- }}
- component={NavLink}
- href="/"
- avatar={
-
- }
- label={getFirstName(
- me?.displayName || (me?.isAnonymous ? "Anonymous" : ""),
- )}
- />
- )}
- {me && (
- x.spacing(1),
- backgroundColor: (x) =>
- x.palette.mode === "light"
- ? x.palette.grey[300]
- : x.palette.grey[700],
- width: 40,
- height: 40,
- }}
- children={}
- onClick={openNotificationsMenu}
- />
- )}
- {me && (
- x.spacing(1),
- backgroundColor: (x) =>
- x.palette.mode === "light"
- ? x.palette.grey[300]
- : x.palette.grey[700],
- width: 40,
- height: 40,
- }}
- children={}
- onClick={openUserMenu}
- />
- )}
- {me === null && (
-
- )}
- {me === null && (
-
- )}
-
-
- {/* Pop-up menus */}
-
-
-
-
- );
-}
-
-function getFirstName(displayName: string): string {
- return displayName && displayName.split(" ")[0];
-}
-
-type AppToolbarProps = Omit;
diff --git a/app/layout/components/BaseToolbar.tsx b/app/layout/components/BaseToolbar.tsx
deleted file mode 100644
index 802cf62c1..000000000
--- a/app/layout/components/BaseToolbar.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-/* SPDX-FileCopyrightText: 2014-present Kriasoft */
-/* SPDX-License-Identifier: MIT */
-
-import { Close } from "@mui/icons-material";
-import { AppBar, AppBarProps, Box, IconButton, Toolbar } from "@mui/material";
-import { Link } from "../../common/Link.js";
-import { Logo } from "./Logo.js";
-
-export function BaseToolbar(props: AppBarProps): JSX.Element {
- return (
-
-
- {/* Name / Logo */}
- }
- href="/"
- />
-
- {/* Spacer */}
-
-
- {/* Close button */}
-
-
-
-
-
- );
-}
diff --git a/app/layout/components/LoginButton.tsx b/app/layout/components/LoginButton.tsx
deleted file mode 100644
index 70356dcdf..000000000
--- a/app/layout/components/LoginButton.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-/* SPDX-FileCopyrightText: 2014-present Kriasoft */
-/* SPDX-License-Identifier: MIT */
-
-import { Button, ButtonProps } from "@mui/material";
-import * as React from "react";
-import { type SignInMethod } from "../../core/auth.js";
-import { type ExistingAccount } from "../../core/firebase.js";
-import { AuthIcon } from "../../icons/AuthIcon.js";
-
-export function LoginButton(props: LoginButtonProps): JSX.Element {
- const { method, onClick, linkTo, ...other } = props;
- const handleClick = useHandleClick(method, onClick, linkTo);
-
- return (
- }
- fullWidth
- {...other}
- onClick={handleClick}
- >
-
- {method === "google.com"
- ? "Continue with Google"
- : method === "facebook.com"
- ? "Continue with Facebook"
- : method === "anonymous"
- ? "Continue as Anonymous"
- : `Continue with ${method}`}
-
-
- );
-}
-
-function useHandleClick(
- method: SignInMethod,
- onClick?: LoginButtonProps["onClick"],
- linkTo?: ExistingAccount,
-) {
- return React.useCallback(
- (event: React.MouseEvent) => {
- onClick?.(event, method, linkTo);
- },
- [method, onClick, linkTo],
- );
-}
-
-export type LoginButtonProps = ButtonProps<
- "a",
- {
- method: SignInMethod;
- linkTo?: ExistingAccount;
- onClick?: (
- event: React.MouseEvent,
- method: SignInMethod,
- linkTo?: ExistingAccount,
- ) => void;
- }
->;
diff --git a/app/layout/components/Logo.tsx b/app/layout/components/Logo.tsx
deleted file mode 100644
index 59c760993..000000000
--- a/app/layout/components/Logo.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-/* SPDX-FileCopyrightText: 2014-present Kriasoft */
-/* SPDX-License-Identifier: MIT */
-
-import { Typography, TypographyProps } from "@mui/material";
-import { config } from "../../core/config.js";
-
-export function Logo(props: TypographyProps): JSX.Element {
- const { sx, ...other } = props;
-
- return (
-
- {config.app.name}
-
- );
-}
diff --git a/app/layout/components/NotificationsMenu.tsx b/app/layout/components/NotificationsMenu.tsx
deleted file mode 100644
index 4149c25b2..000000000
--- a/app/layout/components/NotificationsMenu.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-/* SPDX-FileCopyrightText: 2014-present Kriasoft */
-/* SPDX-License-Identifier: MIT */
-
-import { ListItemText, Menu, MenuItem, MenuProps } from "@mui/material";
-
-export function NotificationsMenu(props: NotificationsMenuProps): JSX.Element {
- const { PaperProps, ...other } = props;
-
- return (
-
- );
-}
-
-type NotificationsMenuProps = Omit<
- MenuProps,
- "id" | "role" | "open" | "anchorOrigin" | "transformOrigin"
->;
diff --git a/app/layout/components/ThemeButton.tsx b/app/layout/components/ThemeButton.tsx
deleted file mode 100644
index a14ad0172..000000000
--- a/app/layout/components/ThemeButton.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-/* SPDX-FileCopyrightText: 2014-present Kriasoft */
-/* SPDX-License-Identifier: MIT */
-
-import { DarkMode, LightMode } from "@mui/icons-material";
-import { IconButton, IconButtonProps } from "@mui/material";
-import { useTheme } from "@mui/material/styles";
-import { useToggleTheme } from "../../theme/index.js";
-
-export function ThemeButton(props: ThemeButtonProps): JSX.Element {
- const { ...other } = props;
- const toggleTheme = useToggleTheme();
- const theme = useTheme();
-
- return (
-
- {theme.palette.mode === "light" ? : }
-
- );
-}
-
-export type ThemeButtonProps = Omit;
diff --git a/app/layout/components/UserMenu.tsx b/app/layout/components/UserMenu.tsx
deleted file mode 100644
index 326fb9be0..000000000
--- a/app/layout/components/UserMenu.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-/* SPDX-FileCopyrightText: 2014-present Kriasoft */
-/* SPDX-License-Identifier: MIT */
-
-import { Brightness4, Logout, Settings } from "@mui/icons-material";
-import {
- Link,
- ListItemIcon,
- ListItemText,
- Menu,
- MenuItem,
- MenuProps,
- Switch,
-} from "@mui/material";
-import * as React from "react";
-import { Link as NavLink, useNavigate } from "react-router-dom";
-import { useSignOut } from "../../core/auth.js";
-import { useTheme, useToggleTheme } from "../../theme/index.js";
-
-export function UserMenu(props: UserMenuProps): JSX.Element {
- const { PaperProps, MenuListProps, ...other } = props;
- const close = useClose(props.onClose);
- const signOut = useHandleSignOut(props.onClose);
- const toggleTheme = useToggleTheme();
- const theme = useTheme();
-
- return (
-
- );
-}
-
-function useClose(onClose?: MenuProps["onClose"]) {
- return React.useCallback(
- (event: React.MouseEvent) => {
- onClose?.(event, "backdropClick");
- },
- [onClose],
- );
-}
-
-function useHandleSignOut(onClose?: MenuProps["onClose"]) {
- const navigate = useNavigate();
- const signOut = useSignOut();
-
- return React.useCallback(
- (event: React.MouseEvent) => {
- event.preventDefault();
- onClose?.(event, "backdropClick");
- signOut().then(() => navigate("/"));
- },
- [onClose, signOut, navigate],
- );
-}
-
-export type UserMenuProps = Omit;
diff --git a/app/package.json b/app/package.json
index 8c1e1c918..49a6464d5 100644
--- a/app/package.json
+++ b/app/package.json
@@ -19,22 +19,25 @@
"@babel/runtime": "^7.23.5",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
+ "@mui/base": "^5.0.0-beta.25",
"@mui/icons-material": "^5.14.19",
+ "@mui/joy": "^5.0.0-beta.16",
"@mui/lab": "^5.0.0-alpha.154",
"@mui/material": "^5.14.19",
"firebase": "^10.7.0",
+ "jotai": "^2.6.0",
+ "jotai-effect": "^0.2.3",
"localforage": "^1.10.0",
"notistack": "^3.0.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
- "react-router-dom": "^6.20.1",
- "recoil": "^0.7.7"
+ "react-router-dom": "^6.20.1"
},
"devDependencies": {
"@babel/core": "^7.23.5",
"@emotion/babel-plugin": "^11.11.0",
"@types/node": "^20.10.2",
- "@types/react": "^18.2.40",
+ "@types/react": "^18.2.41",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.0",
"envars": "^1.0.2",
diff --git a/app/routes/auth/Login.hooks.ts b/app/routes/auth/Login.hooks.ts
deleted file mode 100644
index ce9df50a1..000000000
--- a/app/routes/auth/Login.hooks.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-/* SPDX-FileCopyrightText: 2014-present Kriasoft */
-/* SPDX-License-Identifier: MIT */
-
-import * as React from "react";
-import { useNavigate } from "react-router-dom";
-import { SignInMethod, signIn } from "../../core/firebase.js";
-
-/**
- * Handles login / signup via Email
- */
-export function useHandleSubmit(
- state: State,
-): [submit: React.FormEventHandler, inFlight: boolean] {
- const [inFlight, setInFlight] = React.useState(false);
-
- return [
- React.useCallback(
- async (event) => {
- event.preventDefault();
- try {
- setInFlight(true);
- console.log(state.email);
- await new Promise((resolve) => setTimeout(resolve, 1000));
- throw new Error("Not implemented");
- } finally {
- setInFlight(false);
- }
- },
- [state.email],
- ),
- inFlight,
- ];
-}
-
-/**
- * The initial state of the Login component
- */
-export function useState() {
- return React.useState({
- email: "",
- code: "",
- saml: false,
- otpSent: undefined as boolean | null | undefined,
- error: undefined as string | null | undefined,
- });
-}
-
-export function useHandleChange(setState: SetState) {
- return React.useCallback(
- function (event: React.ChangeEvent) {
- const { name, value } = event.target as Input;
- setState((prev) =>
- prev[name] === value ? prev : { ...prev, [name]: value },
- );
- },
- [setState],
- );
-}
-
-export function useSwitchSAML(setState: SetState) {
- return React.useCallback(
- (event: React.MouseEvent) => {
- event.preventDefault();
- setState((prev) => ({
- ...prev,
- saml: !prev.saml,
- otpSent: false,
- code: "",
- }));
- },
- [setState],
- );
-}
-
-export function useHandleSignIn(setState: SetState) {
- const navigate = useNavigate();
-
- return React.useCallback(
- async function (event: React.MouseEvent) {
- try {
- const method = event.currentTarget.dataset.method as SignInMethod;
- const credential = await signIn({ method });
- if (credential.user) {
- setState((prev) => (prev.error ? { ...prev, error: null } : prev));
- navigate("/");
- }
- } catch (err) {
- const error = (err as Error)?.message ?? "Login failed.";
- setState((prev) => ({ ...prev, error }));
- }
- },
- [navigate, setState],
- );
-}
-
-export type Mode = "login" | "signup";
-export type State = ReturnType[0];
-export type SetState = ReturnType[1];
-export type Input = { name: keyof State; value: string };
diff --git a/app/routes/auth/Login.tsx b/app/routes/auth/Login.tsx
deleted file mode 100644
index 2a90d5725..000000000
--- a/app/routes/auth/Login.tsx
+++ /dev/null
@@ -1,198 +0,0 @@
-/* SPDX-FileCopyrightText: 2014-present Kriasoft */
-/* SPDX-License-Identifier: MIT */
-
-import {
- Alert,
- Button,
- Container,
- Divider,
- Link,
- TextField,
- Typography,
-} from "@mui/material";
-import { useLocation } from "react-router-dom";
-import { AuthIcon } from "../../icons/AuthIcon.js";
-import {
- useHandleChange,
- useHandleSignIn,
- useHandleSubmit,
- useState,
- useSwitchSAML,
-} from "./Login.hooks.js";
-import { Notice } from "./Notice.js";
-
-/**
- * The login and registration page inspired by Notion. Example:
- *
- * https://www.notion.so/login
- * https://www.notion.so/signup
- */
-export function Component(): JSX.Element {
- const [state, setState] = useState();
- const handleChange = useHandleChange(setState);
- const handleSignIn = useHandleSignIn(setState);
- const [handleSubmit, submitInFlight] = useHandleSubmit(state);
- const switchSAML = useSwitchSAML(setState);
- const { pathname, search } = useLocation();
- const isSignUp = pathname === "/signup";
-
- return (
-
-
-
- {state.error && (
-
- )}
-
- {state.otpSent && (
-
- Please enter the One Time Password (OTP) that has been sent to your
- email address.
-
- )}
-
-
-
-
-
-
- You can also{" "}
-
- continue with {state.saml ? "email" : "SAML SSO"}
-
-
-
-
-
-
- );
-}
-
-Component.displayName = "Login";
diff --git a/app/routes/auth/Notice.tsx b/app/routes/auth/Notice.tsx
deleted file mode 100644
index c1344101b..000000000
--- a/app/routes/auth/Notice.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-/* SPDX-FileCopyrightText: 2014-present Kriasoft */
-/* SPDX-License-Identifier: MIT */
-
-import { Link, Typography, TypographyProps } from "@mui/material";
-import { config } from "../../core/config.js";
-
-export function Notice(props: NoticeProps): JSX.Element {
- const { sx, ...other } = props;
-
- return (
-
-
- By clicking Continue above, your acknowledge that your have read and
- understood, and agree to {config.app.name}'s
- {" "}
-
- Terms & Conditions
-
- and
-
- Privacy Policy
-
- .
-
- );
-}
-
-type NoticeProps = Omit;
diff --git a/app/routes/dashboard.tsx b/app/routes/dashboard.tsx
new file mode 100644
index 000000000..7bff924b8
--- /dev/null
+++ b/app/routes/dashboard.tsx
@@ -0,0 +1,46 @@
+/* SPDX-FileCopyrightText: 2014-present Kriasoft */
+/* SPDX-License-Identifier: MIT */
+
+import { Box, Card, CardContent, Container, Typography } from "@mui/joy";
+import { usePageEffect } from "../core/page";
+
+export const Component = function Dashboard(): JSX.Element {
+ usePageEffect({ title: "Dashboard" });
+
+ return (
+
+
+ Dashboard
+
+
+
+
+
+ Card title
+ Card content
+
+
+
+
+
+ Card title
+ Card content
+
+
+
+
+
+ Card title
+ Card content
+
+
+
+
+ );
+};
diff --git a/app/routes/dashboard/Dashboard.tsx b/app/routes/dashboard/Dashboard.tsx
deleted file mode 100644
index 368fc8bf7..000000000
--- a/app/routes/dashboard/Dashboard.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-/* SPDX-FileCopyrightText: 2014-present Kriasoft */
-/* SPDX-License-Identifier: MIT */
-
-import { Api, GitHub } from "@mui/icons-material";
-import { Box, Button, Container, Typography } from "@mui/material";
-import { usePageEffect } from "../../core/page.js";
-
-export function Component(): JSX.Element {
- usePageEffect({ title: "React App" });
-
- return (
-
-
- Welcome to React Starter Kit!
-
-
-
- The web's most popular Jamstack React template.
-
-
-
- }
- />
- }
- />
-
-
- );
-}
-
-Component.displayName = "Dashboard";
diff --git a/app/routes/index.tsx b/app/routes/index.tsx
index 87d521f46..721636ec5 100644
--- a/app/routes/index.tsx
+++ b/app/routes/index.tsx
@@ -1,10 +1,13 @@
/* SPDX-FileCopyrightText: 2014-present Kriasoft */
/* SPDX-License-Identifier: MIT */
-import { createBrowserRouter, Navigate } from "react-router-dom";
-import { AppLayout } from "../layout/AppLayout.js";
-import { BaseLayout } from "../layout/BaseLayout.js";
-import { RootError } from "../layout/RootError.js";
+import { createElement } from "react";
+import {
+ createBrowserRouter,
+ Navigate,
+ RouterProvider,
+} from "react-router-dom";
+import { BaseLayout, MainLayout, RootError } from "../components";
/**
* Application routes
@@ -16,34 +19,28 @@ export const router = createBrowserRouter([
element: ,
errorElement: ,
children: [
- { path: "login", lazy: () => import("./auth/Login.js") },
- { path: "signup", lazy: () => import("./auth/Login.js") },
- { path: "privacy", lazy: () => import("./legal/Privacy.js") },
- { path: "terms", lazy: () => import("./legal/Terms.js") },
+ { path: "login", lazy: () => import("./login") },
+ { path: "privacy", lazy: () => import("./privacy") },
+ { path: "terms", lazy: () => import("./terms") },
],
},
{
path: "",
- element: ,
+ element: ,
errorElement: ,
children: [
{ index: true, element: },
- { path: "dashboard", lazy: () => import("./dashboard/Dashboard.js") },
- {
- path: "settings",
- lazy: () => import("./settings/SettingsLayout.js"),
- children: [
- { index: true, element: },
- {
- path: "account",
- lazy: () => import("./settings/AccountDetails.js"),
- },
- ],
- },
+ { path: "dashboard", lazy: () => import("./dashboard") },
+ { path: "tasks", lazy: () => import("./tasks") },
+ { path: "messages", lazy: () => import("./messages") },
],
},
]);
+export function Router(): JSX.Element {
+ return createElement(RouterProvider, { router });
+}
+
// Clean up on module reload (HMR)
// https://vitejs.dev/guide/api-hmr
if (import.meta.hot) {
diff --git a/app/routes/login.tsx b/app/routes/login.tsx
new file mode 100644
index 000000000..b320613bb
--- /dev/null
+++ b/app/routes/login.tsx
@@ -0,0 +1,30 @@
+/* SPDX-FileCopyrightText: 2014-present Kriasoft */
+/* SPDX-License-Identifier: MIT */
+
+import { Container, ContainerProps, Typography } from "@mui/joy";
+import { LoginButton } from "../components";
+
+export const Component = function Login(): JSX.Element {
+ return (
+
+
+ Sign In
+
+
+
+
+
+ );
+};
+
+export type LoginProps = Omit;
diff --git a/app/routes/messages.tsx b/app/routes/messages.tsx
new file mode 100644
index 000000000..934b19b52
--- /dev/null
+++ b/app/routes/messages.tsx
@@ -0,0 +1,18 @@
+/* SPDX-FileCopyrightText: 2014-present Kriasoft */
+/* SPDX-License-Identifier: MIT */
+
+import { Container, Typography } from "@mui/joy";
+import { usePageEffect } from "../core/page";
+
+export const Component = function Messages(): JSX.Element {
+ usePageEffect({ title: "Messages" });
+
+ return (
+
+
+ Messages
+
+ Coming soon...
+
+ );
+};
diff --git a/app/routes/legal/Privacy.tsx b/app/routes/privacy.tsx
similarity index 85%
rename from app/routes/legal/Privacy.tsx
rename to app/routes/privacy.tsx
index 8067ec165..4455beba6 100644
--- a/app/routes/legal/Privacy.tsx
+++ b/app/routes/privacy.tsx
@@ -1,52 +1,48 @@
/* SPDX-FileCopyrightText: 2014-present Kriasoft */
/* SPDX-License-Identifier: MIT */
-import { Container, Link, Typography } from "@mui/material";
-import { config } from "../../core/config.js";
-import { usePageEffect } from "../../core/page.js";
+import { Container, Link, Typography } from "@mui/joy";
+import { usePageEffect } from "../core/page";
+
+const appName = import.meta.env.VITE_APP_NAME;
+const appOrigin = import.meta.env.VITE_APP_ORIGIN;
+const email = `hello@${import.meta.env.VITE_APP_HOSTNAME}`;
/**
* Generated by https://getterms.io
*/
-export function Component(): JSX.Element {
+export const Component = function Privacy(): JSX.Element {
usePageEffect({ title: "Privacy Policy" });
- const appName = config.app.name;
- const appOrigin = config.app.origin;
- const email = `hello@${config.app.hostname}`;
-
return (
- x.spacing(3), marginBottom: (x) => x.spacing(3) }}
- >
-
+
+
Privacy Policy
-
+
Your privacy is important to us. It is {appName}' policy to respect
your privacy and comply with any applicable law and regulation regarding
any personal information we may collect about you, including across our
website, {appOrigin}, and other sites we
own and operate.
-
+
This policy is effective as of 28 February 2021 and was last updated on
28 February 2021.
-
+
Information We Collect
-
+
Information we collect includes both information you knowingly and
actively provide us when using or participating in any of our services
and promotions, and any information automatically sent by your devices
in the course of accessing our products and services.
-
+
Log Data
-
+
When you visit our website, our servers may automatically log the
standard data provided by your web browser. It may include your deviceβs
Internet Protocol (IP) address, your browser type and version, the pages
@@ -54,15 +50,15 @@ export function Component(): JSX.Element {
other details about your visit, and technical details that occur in
conjunction with any errors you may encounter.
-
+
Please be aware that while this information may not be personally
identifying by itself, it may be possible to combine it with other data
to personally identify individual persons.
-
+
Personal Information
-
+
We may ask for personal information which may include one or more of the
following:
@@ -71,19 +67,19 @@ export function Component(): JSX.Element {
Email
Social media profiles
-
+
Legitimate Reasons for Processing Your Personal Information
-
+
We only collect and use your personal information when we have a
legitimate reason for doing so. In which instance, we only collect
personal information that is reasonably necessary to provide our
services to you.
-
+
Collection and Use of Information
-
+
We may collect personal information from you when you do any of the
following on our website:
@@ -97,12 +93,12 @@ export function Component(): JSX.Element {
When you mention us on social media
-
+
We may collect, hold, use, and disclose information for the following
purposes, and personal information will not be further processed in a
manner that is incompatible with these purposes:
-
+
We may collect, hold, use, and disclose information for the following
purposes, and personal information will not be further processed in a
manner that is incompatible with these purposes:
@@ -129,59 +125,59 @@ export function Component(): JSX.Element {
for internal record keeping and administrative purposes
-
+
Please be aware that we may combine information we collect about you
with general information or research data we receive from other trusted
sources.
-
+
Security of Your Personal Information
-
+
When we collect and process personal information, and while we retain
this information, we will protect it within commercially acceptable
means to prevent loss and theft, as well as unauthorized access,
disclosure, copying, use, or modification.
-
+
Although we will do our best to protect the personal information you
provide to us, we advise that no method of electronic transmission or
storage is 100% secure, and no one can guarantee absolute data security.
We will comply with laws applicable to us in respect of any data breach.
-
+
You are responsible for selecting any password and its overall security
strength, ensuring the security of your own information within the
bounds of our services.
-
+
How Long We Keep Your Personal Information
-
+
We keep your personal information only for as long as we need to. This
time period may depend on what we are using your information for, in
accordance with this privacy policy. If your personal information is no
longer required, we will delete it or make it anonymous by removing all
details that identify you.
-
+
However, if necessary, we may retain your personal information for our
compliance with a legal, accounting, or reporting obligation or for
archiving purposes in the public interest, scientific, or historical
research purposes or statistical purposes.
-
+
Childrenβs Privacy
-
+
We do not aim any of our products or services directly at children under
the age of 13, and we do not knowingly collect personal information
about children under 13.
-
+
Disclosure of Personal Information to Third Parties
-
+
We may disclose personal information to:{" "}
@@ -210,10 +206,10 @@ export function Component(): JSX.Element {
third parties to collect and process data
-
+
International Transfers of Personal Information
-
+
The personal information we collect is stored and/or processed where we
or our partners, affiliates, and third-party providers maintain
facilities. Please be aware that the locations to which we store,
@@ -224,10 +220,10 @@ export function Component(): JSX.Element {
with the requirements of applicable law; and (ii) we will protect the
transferred personal information in accordance with this privacy policy.
-
+
Your Rights and Controlling Your Personal Information
-
+
You always retain the right to withhold personal information from us,
with the understanding that your experience of our website may be
affected. We will not discriminate against you for exercising any of
@@ -236,28 +232,28 @@ export function Component(): JSX.Element {
disclose it in accordance with this privacy policy. You retain the right
to request details of any personal information we hold about you.
-
+
If we receive personal information about you from a third party, we will
protect it as set out in this privacy policy. If you are a third party
providing personal information about somebody else, you represent and
warrant that you have such personβs consent to provide the personal
information to us.
-
+
If you have previously agreed to us using your personal information for
direct marketing purposes, you may change your mind at any time. We will
provide you with the ability to unsubscribe from our email-database or
opt out of communications. Please be aware we may need to request
specific information from you to help us confirm your identity.
-
+
If you believe that any information we hold about you is inaccurate, out
of date, incomplete, irrelevant, or misleading, please contact us using
the details provided in this privacy policy. We will take reasonable
steps to correct any information found to be inaccurate, incomplete,
misleading, or out of date.
-
+
If you believe that we have breached a relevant data protection law and
wish to make a complaint, please contact us using the details below and
provide us with full details of the alleged breach. We will promptly
@@ -266,52 +262,50 @@ export function Component(): JSX.Element {
your complaint. You also have the right to contact a regulatory body or
data protection authority in relation to your complaint.
-
+
Use of Cookies
-
+
We use “cookies” to collect information about you and your
activity across our site. A cookie is a small piece of data that our
website stores on your computer, and accesses each time you visit, so we
can understand how you use our site. This helps us serve you content
based on preferences you have specified.
-
+
Limits of Our Policy
-
+
Our website may link to external sites that are not operated by us.
Please be aware that we have no control over the content and policies of
those sites, and cannot accept responsibility or liability for their
respective privacy practices.
-
+
Changes to This Policy
-
+
At our discretion, we may change our privacy policy to reflect updates
to our business processes, current acceptable practices, or legislative
or regulatory changes. If we decide to change this privacy policy, we
will post the changes here at the same link by which you are accessing
this privacy policy.
-
+
If required by law, we will get your permission or give you the
opportunity to opt in to or opt out of, as applicable, any new uses of
your personal information.
-
+
Contact Us
-
+
For any questions or concerns regarding your privacy, you may contact us
using the following details:
-
+
{appName} Support Team ({email})
);
-}
-
-Component.displayName = "Privacy";
+};
diff --git a/app/routes/settings/AccountDetails.tsx b/app/routes/settings/AccountDetails.tsx
deleted file mode 100644
index b62db06a2..000000000
--- a/app/routes/settings/AccountDetails.tsx
+++ /dev/null
@@ -1,138 +0,0 @@
-/* SPDX-FileCopyrightText: 2014-present Kriasoft */
-/* SPDX-License-Identifier: MIT */
-
-import {
- Alert,
- Box,
- Button,
- Container,
- TextField,
- Typography,
-} from "@mui/material";
-import { updateEmail, updateProfile } from "firebase/auth";
-import * as React from "react";
-import { useAuthCallback, useCurrentUser } from "../../core/auth.js";
-import { usePageEffect } from "../../core/page.js";
-
-export function Component(): JSX.Element {
- const [{ input, ...state }, setState] = useState();
- const handleChange = useHandleChange(setState);
- const handleSubmit = useHandleSubmit(input, setState);
-
- usePageEffect({ title: "Account Details" });
-
- return (
-
-
-
- {state.error && (
-
- )}
-
-
-
-
-
-
-
-
-
- );
-}
-
-function useState() {
- const me = useCurrentUser();
- const [state, setState] = React.useState({
- input: {
- displayName: me?.displayName ?? "",
- email: me?.email ?? "",
- },
- loading: me === undefined,
- error: undefined as string | undefined,
- });
-
- React.useEffect(() => {
- if (me?.uid) {
- setState((prev) => ({
- ...prev,
- input: {
- ...prev.input,
- displayName: me.displayName ?? "",
- email: me.email ?? "",
- },
- loading: false,
- }));
- }
- }, [setState, me?.uid, me?.email, me?.displayName]);
-
- return [state, setState] as const;
-}
-
-function useHandleChange(setState: SetState) {
- return React.useCallback(
- (event: React.ChangeEvent) => {
- const { name, value } = event.target;
- setState((prev) => ({
- ...prev,
- input: { ...prev.input, [name]: value },
- }));
- },
- [setState],
- );
-}
-
-function useHandleSubmit(input: Input, setState: SetState) {
- const saveProfile = useAuthCallback(
- async (me) => {
- await updateProfile(me, { displayName: input.displayName });
- await updateEmail(me, input.email);
- },
- [input.displayName, input.email],
- );
-
- return React.useCallback(
- async (event: React.FormEvent) => {
- event.preventDefault();
- setState((prev) => ({ ...prev, loading: true }));
- try {
- await saveProfile();
- setState((prev) => ({ ...prev, loading: false, error: undefined }));
- } catch (err) {
- const error = (err as Error)?.message ?? "Failed.";
- setState((prev) => ({ ...prev, loading: false, error }));
- }
- },
- [setState, saveProfile],
- );
-}
-
-Component.displayName = "AccountDetails";
-
-type Input = ReturnType[0]["input"];
-type SetState = ReturnType[1];
diff --git a/app/routes/settings/SettingsLayout.tsx b/app/routes/settings/SettingsLayout.tsx
deleted file mode 100644
index 35424e524..000000000
--- a/app/routes/settings/SettingsLayout.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-/* SPDX-FileCopyrightText: 2014-present Kriasoft */
-/* SPDX-License-Identifier: MIT */
-
-import { Box } from "@mui/material";
-import { Outlet } from "react-router-dom";
-
-export function Component(): JSX.Element {
- return (
-
-
-
- );
-}
-
-Component.displayName = "SettingsLayout";
diff --git a/app/routes/tasks.tsx b/app/routes/tasks.tsx
new file mode 100644
index 000000000..3b7af05e3
--- /dev/null
+++ b/app/routes/tasks.tsx
@@ -0,0 +1,18 @@
+/* SPDX-FileCopyrightText: 2014-present Kriasoft */
+/* SPDX-License-Identifier: MIT */
+
+import { Container, Typography } from "@mui/joy";
+import { usePageEffect } from "../core/page";
+
+export const Component = function Tasks(): JSX.Element {
+ usePageEffect({ title: "Tasks" });
+
+ return (
+
+
+ Tasks
+
+ Coming soon...
+
+ );
+};
diff --git a/app/routes/legal/Terms.tsx b/app/routes/terms.tsx
similarity index 85%
rename from app/routes/legal/Terms.tsx
rename to app/routes/terms.tsx
index 3bd125473..d66d14de3 100644
--- a/app/routes/legal/Terms.tsx
+++ b/app/routes/terms.tsx
@@ -1,52 +1,51 @@
/* SPDX-FileCopyrightText: 2014-present Kriasoft */
/* SPDX-License-Identifier: MIT */
-import { Container, Link, Typography } from "@mui/material";
-import { config } from "../../core/config.js";
-import { usePageEffect } from "../../core/page.js";
+import { Container, Link, Typography } from "@mui/joy";
+import { usePageEffect } from "../core/page";
+
+const appName = import.meta.env.VITE_APP_NAME;
+const appOrigin = import.meta.env.VITE_APP_ORIGIN;
/**
* Generated by https://getterms.io
*/
-export function Component(): JSX.Element {
+export const Component = function Terms(): JSX.Element {
usePageEffect({ title: "Terms of Use" });
- const appName = config.app.name;
- const appOrigin = config.app.origin;
-
return (
x.spacing(3), marginBottom: (x) => x.spacing(3) }}
>
-
+
Terms of Service
-
+
These Terms of Service govern your use of the website located at{" "}
{appOrigin} and any related services
provided by {appName}.
-
+
By accessing {appOrigin}, you agree to
abide by these Terms of Service and to comply with all applicable laws
and regulations. If you do not agree with these Terms of Service, you
are prohibited from using or accessing this website or using any other
services provided by {appName}.
-
+
We, {appName}, reserve the right to review and amend any of these Terms
of Service at our sole discretion. Upon doing so, we will update this
page. Any changes to these Terms of Service will take effect immediately
from the date of publication.
-
+
These Terms of Service were last updated on 28 February 2021.
-
+
Limitations of Use
-
+
By using this website, you warrant on behalf of yourself, your users,
and other parties you represent that you will not:
@@ -89,25 +88,25 @@ export function Component(): JSX.Element {
third parties.
-
+
Intellectual Property
-
+
The intellectual property in the materials contained in this website are
owned by or licensed to {appName} and are protected by applicable
copyright and trademark law. We grant our users permission to download
one copy of the materials for personal, non-commercial transitory use.
-
+
This constitutes the grant of a license, not a transfer of title. This
license shall automatically terminate if you violate any of these
restrictions or the Terms of Service, and may be terminated by {appName}
at any time.
-
+
Liability
-
+
Our website and the materials on our website are provided on an
‘as is’ basis. To the extent permitted by law, {appName}{" "}
makes no warranties, expressed or implied, and hereby disclaims and
@@ -116,14 +115,14 @@ export function Component(): JSX.Element {
purpose, or non-infringement of intellectual property, or other
violation of rights.
-
+
In no event shall {appName} or its suppliers be liable for any
consequential loss suffered or incurred by you or any third party
arising from the use or inability to use this website or the materials
on this website, even if {appName} or an authorized representative has
been notified, orally or in writing, of the possibility of such damage.
-
+
In the context of this agreement, “consequential loss”
includes any consequential loss, indirect loss, real or anticipated loss
of profit, loss of benefit, loss of revenue, loss of business, loss of
@@ -131,25 +130,25 @@ export function Component(): JSX.Element {
of use and/or loss or corruption of data, whether under statute,
contract, equity, tort (including negligence), indemnity, or otherwise.
-
+
Because some jurisdictions do not allow limitations on implied
warranties, or limitations of liability for consequential or incidental
damages, these limitations may not apply to you.
-
+
Accuracy of Materials
-
+
The materials appearing on our website are not comprehensive and are for
general information purposes only. {appName} does not warrant or make
any representations concerning the accuracy, likely results, or
reliability of the use of the materials on this website, or otherwise
relating to such materials or on any resources linked to this website.
-
+
Links
-
+
{appName} has not reviewed all of the sites linked to its website and is
not responsible for the contents of any such linked site. The inclusion
of any link does not imply endorsement, approval, or control by
@@ -157,32 +156,30 @@ export function Component(): JSX.Element {
and we strongly advise you make your own investigations with respect to
the suitability of those sites.
-
+
Right to Terminate
-
+
We may suspend or terminate your right to use our website and terminate
these Terms of Service immediately upon written notice to you for any
breach of these Terms of Service.
-
+
Severance
-
+
Any term of these Terms of Service which is wholly or partially void or
unenforceable is severed to the extent that it is void or unenforceable.
The validity of the remainder of these Terms of Service is not affected.
-
+
Governing Law
-
+
These Terms of Service are governed by and construed in accordance with
the laws of United States. You irrevocably submit to the exclusive
jurisdiction of the courts in that State or location.
);
-}
-
-Component.displayName = "Terms";
+};
diff --git a/app/theme/README.md b/app/theme/README.md
deleted file mode 100644
index 2cf91abe4..000000000
--- a/app/theme/README.md
+++ /dev/null
@@ -1,68 +0,0 @@
-# Material UI Theme
-
-This folder contains customizations for the `light` and `dark` Material UI themes such as the color of the components, darkness of the surfaces, level of shadow, appropriate opacity of ink elements, etc. It allows to customize all design aspects of the project in order to meet the specific needs of the business or brand.
-
-## Usage Example
-
-For themes to work the top-level React component needs to be wrapped into the `` component (see [`./index.tsx`](../index.tsx)):
-
-```tsx
-import ReactDOM from "react-dom/client";
-import { RouterProvider } from "react-router-dom";
-import { ThemeProvider } from "./theme/index.js";
-import { router } from "./routes/index.js";
-
-const root = ReactDOM.createRoot(document.elementById("root"));
-
-root.render(
-
-
-
- ,
-);
-```
-
-How to access the current (selected) theme from under a React component:
-
-```tsx
-import { useTheme } from "@mui/material";
-
-function Example(): JSX.Element {
- const theme = useTheme();
- return (...);
-}
-```
-
-It is also possible to access a specific theme (`light`, `dark`, etc.):
-
-```tsx
-import { useTheme } from "./theme/index.js";
-
-function Example(): JSX.Element {
- const theme = useTheme("dark");
- return (...);
-}
-```
-
-Switch between themes by using `useToggleTheme()` React hook:
-
-```tsx
-import { useTheme, useToggleTheme } from "./theme/index.js";
-
-function Example(): JSX.Element {
- const thee = useTheme();
- const toggleTheme = useToggleTheme();
-
- return (
-
- Switch to {theme.palette.mode === "light" ? "Dark" : "Light"}
-
- );
-}
-```
-
-## References
-
-- [Material UI theming](https://mui.com/material-ui/customization/theming/)
-- [Material UI default theme viewer](https://mui.com/material-ui/customization/default-theme/) ([source code](https://github.com/mui/material-ui/tree/master/packages/mui-material/src/styles))
-- [How to implement UI theme switching (dark mode) with Recoil?](https://github.com/kriasoft/react-starter-kit/discussions/1987)
diff --git a/app/theme/components.ts b/app/theme/components.ts
deleted file mode 100644
index ae4064be7..000000000
--- a/app/theme/components.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-/* SPDX-FileCopyrightText: 2014-present Kriasoft */
-/* SPDX-License-Identifier: MIT */
-
-import { type Palette, type ThemeOptions } from "@mui/material/styles";
-
-/**
- * Style overrides for Material UI components.
- */
-/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
-export const components = (palette: Palette): ThemeOptions["components"] => ({
- MuiButton: {
- styleOverrides: {
- root: {
- textTransform: "unset",
- },
- contained: {
- boxShadow: "none",
- "&:hover": {
- boxShadow: "none",
- },
- },
- },
- },
-
- MuiButtonGroup: {
- styleOverrides: {
- root: {
- boxShadow: "none",
- },
- },
- },
-});
diff --git a/app/theme/index.tsx b/app/theme/index.tsx
deleted file mode 100644
index 0bddda56e..000000000
--- a/app/theme/index.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-/* SPDX-FileCopyrightText: 2014-present Kriasoft */
-/* SPDX-License-Identifier: MIT */
-
-import {
- ThemeProvider as MuiThemeProvider,
- type PaletteMode,
-} from "@mui/material";
-import { createTheme } from "@mui/material/styles";
-import {
- atom,
- selectorFamily,
- useRecoilCallback,
- useRecoilValue,
-} from "recoil";
-import { components } from "./components.js";
-import palettes from "./palettes.js";
-import * as typography from "./typography.js";
-
-/**
- * The name of the selected UI theme.
- */
-export const ThemeName = atom({
- key: "ThemeName",
- effects: [
- (ctx) => {
- const storageKey = "theme";
-
- if (ctx.trigger === "get") {
- const name: PaletteMode =
- localStorage?.getItem(storageKey) === "dark"
- ? "dark"
- : localStorage?.getItem(storageKey) === "light"
- ? "light"
- : matchMedia?.("(prefers-color-scheme: dark)").matches
- ? "dark"
- : "light";
- ctx.setSelf(name);
- }
-
- ctx.onSet((value) => {
- localStorage?.setItem(storageKey, value);
- });
- },
- ],
-});
-
-/**
- * The customized Material UI theme.
- * @see https://next.material-ui.com/customization/default-theme/
- */
-export const Theme = selectorFamily({
- key: "Theme",
- dangerouslyAllowMutability: true,
- get(name: PaletteMode) {
- return function () {
- const { palette } = createTheme({ palette: palettes[name] });
- return createTheme(
- {
- palette,
- typography: typography.options,
- components: components(palette),
- },
- {
- typography: typography.overrides,
- },
- );
- };
- },
-});
-
-/**
- * Returns a customized Material UI theme.
- *
- * @param name - The name of the requested theme. Defaults to the
- * auto-detected or user selected value.
- */
-export function useTheme(name?: PaletteMode) {
- const selected = useRecoilValue(ThemeName);
- return useRecoilValue(Theme(name ?? selected));
-}
-
-/**
- * Switches between "light" and "dark" themes.
- */
-export function useToggleTheme(name?: PaletteMode) {
- return useRecoilCallback(
- (ctx) => () => {
- ctx.set(
- ThemeName,
- name ?? ((prev) => (prev === "dark" ? "light" : "dark")),
- );
- },
- [],
- );
-}
-
-/**
- * This component makes the `theme` available down the React tree.
- */
-export function ThemeProvider(props: {
- children: React.ReactNode;
-}): JSX.Element {
- return ;
-}
diff --git a/app/theme/palettes.ts b/app/theme/palettes.ts
deleted file mode 100644
index 9ef0db14c..000000000
--- a/app/theme/palettes.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-/* SPDX-FileCopyrightText: 2014-present Kriasoft */
-/* SPDX-License-Identifier: MIT */
-
-import { type PaletteOptions } from "@mui/material/styles";
-
-export const light: PaletteOptions = {
- mode: "light",
-
- primary: {
- main: "rgb(24,119,242)",
- },
-
- background: {
- default: "rgb(240,242,245)",
- },
-
- example: {
- primary: "#49b4ff",
- secondary: "#ef3054",
- },
-};
-
-export const dark: PaletteOptions = {
- mode: "dark",
-
- primary: {
- main: "rgb(45,136,255)",
- },
-
- background: {
- default: "rgb(24,25,26)",
- },
-
- example: {
- primary: "#49b4ff",
- secondary: "#ef3054",
- },
-};
-
-export default { light, dark };
-
-/**
- * Append custom variables to the palette object.
- * https://mui.com/material-ui/customization/theming/#custom-variables
- */
-declare module "@mui/material/styles" {
- interface Palette {
- example: {
- primary: string;
- secondary: string;
- };
- }
-
- interface PaletteOptions {
- example: {
- primary: string;
- secondary: string;
- };
- }
-}
diff --git a/app/theme/typography.ts b/app/theme/typography.ts
deleted file mode 100644
index 8f702d9c2..000000000
--- a/app/theme/typography.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/* SPDX-FileCopyrightText: 2014-present Kriasoft */
-/* SPDX-License-Identifier: MIT */
-
-import { TypographyVariantsOptions } from "@mui/material/styles";
-
-export const options: TypographyVariantsOptions = {
- fontFamily: [
- `-apple-system`,
- `"BlinkMacSystemFont"`,
- `"Segoe UI"`,
- `"Roboto"`,
- `"Oxygen"`,
- `"Ubuntu"`,
- `"Cantarell"`,
- `"Fira Sans"`,
- `"Droid Sans"`,
- `"Helvetica Neue"`,
- `sans-serif`,
- ].join(","),
-};
-
-export const overrides: TypographyVariantsOptions = {
- h1: { fontSize: "2em" },
- h2: { fontSize: "1.5em" },
- h3: { fontSize: "1.3em" },
- h4: { fontSize: "1em" },
- h5: { fontSize: "0.8em" },
- h6: { fontSize: "0.7em" },
- button: { textTransform: "none" },
-};
diff --git a/app/tsconfig.json b/app/tsconfig.json
index 813d7480c..dbbca3ebd 100644
--- a/app/tsconfig.json
+++ b/app/tsconfig.json
@@ -6,8 +6,8 @@
"jsxImportSource": "@emotion/react",
"types": ["vite/client"],
"outDir": "../.cache/typescript-app",
- "module": "NodeNext",
- "moduleResolution": "NodeNext",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
"noEmit": true
},
"include": ["**/*.ts", "**/*.d.ts", "**/*.tsx", "**/*.json"],
diff --git a/app/vite.config.ts b/app/vite.config.ts
index 2a617d1fb..ad5679816 100644
--- a/app/vite.config.ts
+++ b/app/vite.config.ts
@@ -2,83 +2,71 @@
/* SPDX-License-Identifier: MIT */
import react from "@vitejs/plugin-react";
-import envars from "envars";
-import { resolve } from "node:path";
-import { URL } from "node:url";
+import { URL, fileURLToPath } from "node:url";
+import { loadEnv } from "vite";
import { defineProject } from "vitest/config";
-import { Config, EnvName } from "./core/config.js";
-// The list of supported environments
-const envNames: EnvName[] = ["prod", "test", "local"];
-
-// Bootstrap client-side configuration from environment variables
-const configs = envNames.map((envName): [EnvName, Config] => {
- const envDir = resolve(__dirname, "../env");
- const env = envars.config({ env: envName, cwd: envDir });
- return [
- envName,
- {
- app: {
- env: envName,
- name: env.APP_NAME,
- origin: env.APP_ORIGIN,
- hostname: new URL(env.APP_ORIGIN).hostname,
- },
- firebase: {
- projectId: env.GOOGLE_CLOUD_PROJECT,
- appId: env.FIREBASE_APP_ID,
- apiKey: env.FIREBASE_API_KEY,
- authDomain: env.FIREBASE_AUTH_DOMAIN,
- measurementId: env.GA_MEASUREMENT_ID,
- },
- },
- ];
-});
-
-// Pass client-side configuration to the web app
-// https://vitejs.dev/guide/env-and-mode.html#env-variables-and-modes
-process.env.VITE_CONFIG = JSON.stringify(Object.fromEntries(configs));
+const publicEnvVars = [
+ "APP_ENV",
+ "APP_NAME",
+ "APP_ORIGIN",
+ "GOOGLE_CLOUD_PROJECT",
+ "FIREBASE_APP_ID",
+ "FIREBASE_API_KEY",
+ "FIREBASE_AUTH_DOMAIN",
+ "GA_MEASUREMENT_ID",
+];
/**
- * Vite configuration
+ * Vite configuration.
* https://vitejs.dev/config/
*/
-export default defineProject({
- cacheDir: `../.cache/vite-app`,
+export default defineProject(async ({ mode }) => {
+ const envDir = fileURLToPath(new URL("..", import.meta.url));
+ const env = loadEnv(mode, envDir, "");
- build: {
- rollupOptions: {
- output: {
- manualChunks: {
- firebase: ["firebase/analytics", "firebase/app", "firebase/auth"],
- react: ["react", "react-dom", "react-router-dom", "recoil"],
+ publicEnvVars.forEach((key) => {
+ if (!env[key]) throw new Error(`Missing environment variable: ${key}`);
+ process.env[`VITE_${key}`] = env[key];
+ });
+
+ return {
+ cacheDir: fileURLToPath(new URL("../.cache/vite-app", import.meta.url)),
+
+ build: {
+ rollupOptions: {
+ output: {
+ manualChunks: {
+ firebase: ["firebase/analytics", "firebase/app", "firebase/auth"],
+ react: ["react", "react-dom", "react-router-dom"],
+ },
},
},
},
- },
- plugins: [
- // The default Vite plugin for React projects
- // https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md
- react({
- jsxImportSource: "@emotion/react",
- babel: {
- plugins: ["@emotion/babel-plugin"],
- },
- }),
- ],
+ plugins: [
+ // The default Vite plugin for React projects
+ // https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md
+ react({
+ jsxImportSource: "@emotion/react",
+ babel: {
+ plugins: ["@emotion/babel-plugin"],
+ },
+ }),
+ ],
- server: {
- proxy: {
- "/api": {
- target: process.env.LOCAL_API_ORIGIN ?? process.env.API_ORIGIN,
- changeOrigin: true,
+ server: {
+ proxy: {
+ "/api": {
+ target: process.env.LOCAL_API_ORIGIN ?? process.env.API_ORIGIN,
+ changeOrigin: true,
+ },
},
},
- },
- test: {
- ...{ cache: { dir: "../.cache/vitest" } },
- environment: "happy-dom",
- },
+ test: {
+ ...{ cache: { dir: "../.cache/vitest" } },
+ environment: "happy-dom",
+ },
+ };
});
diff --git a/edge/core/email.ts b/edge/core/email.ts
index a70e0a878..4b0691846 100644
--- a/edge/core/email.ts
+++ b/edge/core/email.ts
@@ -39,13 +39,13 @@ export function sendEmail(options: Options) {
const res = await fetch(req, options.req);
if (!res.ok) {
- const body = await res.json().catch(() => undefined);
+ const body = await res.json().catch(() => undefined);
console.error({
req: { url: req.url, method: req.method },
res: {
status: res.status,
statusText: res.statusText,
- errors: body?.errors,
+ errors: (body as ErrorResponse)?.errors,
},
});
diff --git a/edge/routes/api-swapi.ts b/edge/routes/api-swapi.ts
index 46ad5762f..8c82ba511 100644
--- a/edge/routes/api-swapi.ts
+++ b/edge/routes/api-swapi.ts
@@ -5,12 +5,13 @@ import { app } from "../core/app.js";
// Rewrite HTTP requests starting with "/api/"
// to the Star Wars API as an example
-export const handler = app.use("/api/*", ({ req }) => {
+export const handler = app.use("/api/*", async ({ req }) => {
const { pathname, search } = new URL(req.url);
- return fetch(
+ const res = await fetch(
`https://swapi.dev${pathname}${search}`,
req.raw as RequestInit,
);
+ return res as unknown as Response;
});
export type SwapiHandler = typeof handler;
diff --git a/edge/routes/firebase.ts b/edge/routes/firebase.ts
index 814b28346..bf5d62c59 100644
--- a/edge/routes/firebase.ts
+++ b/edge/routes/firebase.ts
@@ -1,12 +1,13 @@
/* SPDX-FileCopyrightText: 2014-present Kriasoft */
/* SPDX-License-Identifier: MIT */
-import { app } from "../core/app.js";
+import { app } from "../core/app";
-export const handler = app.all("/__/*", ({ req, env }) => {
+export const handler = app.use("/__/*", async ({ req, env }) => {
const url = new URL(req.url);
const origin = `https://${env.GOOGLE_CLOUD_PROJECT}.web.app`;
- return fetch(`${origin}${url.pathname}${url.search}`, req);
+ const res = await fetch(`${origin}${url.pathname}${url.search}`, req.raw);
+ return res as unknown as Response;
});
export type FirebaseHandler = typeof handler;
diff --git a/env/.prod.env b/env/.prod.env
deleted file mode 100644
index 78d70413b..000000000
--- a/env/.prod.env
+++ /dev/null
@@ -1,40 +0,0 @@
-# Environment variables for the production deployment
-#
-# NOTE: You can place secrets and local overrides into the
-# ".prod.override.env" file which is excluded from this Git repo
-
-# Web application settings
-APP_ENV=prod
-APP_NAME=React App
-APP_HOSTNAME=example.com
-APP_ORIGIN=https://example.com
-API_ORIGIN=https://example.com
-
-# Google Cloud
-# https://console.cloud.google.com/
-GOOGLE_CLOUD_PROJECT=
-GOOGLE_CLOUD_REGION=us-central1
-GOOGLE_CLOUD_CREDENTIALS={"type":"service_account","project_id":"example","private_key_id":"xxx","private_key":"-----BEGIN PRIVATE KEY-----\nxxxxx\n-----END PRIVATE KEY-----\n","client_email":"application@exmaple.iam.gserviceaccount.com","client_id":"xxxxx","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_x509_cert_url":"https://www.googleapis.com/robot/v1/metadata/x509/application%40example.iam.gserviceaccount.com"}
-
-# Firebase
-# https://console.firebase.google.com/
-FIREBASE_APP_ID=
-FIREBASE_API_KEY=
-FIREBASE_AUTH_DOMAIN=
-
-# Cloudflare
-# https://dash.cloudflare.com/
-# https://developers.cloudflare.com/api/tokens/create
-CLOUDFLARE_ACCOUNT_ID=
-CLOUDFLARE_ZONE_ID=
-CLOUDFLARE_API_TOKEN=
-
-# Google Analytics (v4)
-# https://console.firebase.google.com/
-# https://firebase.google.com/docs/analytics/get-started?platform=web
-GA_MEASUREMENT_ID=G-XXXXXXXX
-
-# SendGrid
-# https://app.sendgrid.com/settings/api_keys
-SENDGRID_API_KEY=xxxxx
-FROM_EMAIL=hello@example.com
diff --git a/env/.test.env b/env/.test.env
deleted file mode 100644
index 5978a505a..000000000
--- a/env/.test.env
+++ /dev/null
@@ -1,40 +0,0 @@
-# Environment variables for the test/QA deployment
-#
-# NOTE: You can place secrets and local overrides into the
-# ".test.override.env" file which is excluded from this Git repo
-
-# Web application settings
-APP_ENV=test
-APP_NAME=React App
-APP_HOSTNAME=test.example.com
-APP_ORIGIN=https://test.example.com
-API_ORIGIN=https://test.example.com
-
-# Google Cloud
-# https://console.cloud.google.com/
-GOOGLE_CLOUD_PROJECT=
-GOOGLE_CLOUD_REGION=us-central1
-GOOGLE_CLOUD_CREDENTIALS={"type":"service_account","project_id":"example","private_key_id":"xxx","private_key":"-----BEGIN PRIVATE KEY-----\nxxxxx\n-----END PRIVATE KEY-----\n","client_email":"application@exmaple.iam.gserviceaccount.com","client_id":"xxxxx","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_x509_cert_url":"https://www.googleapis.com/robot/v1/metadata/x509/application%40example.iam.gserviceaccount.com"}
-
-# Firebase
-# https://console.firebase.google.com/
-FIREBASE_APP_ID=
-FIREBASE_API_KEY=
-FIREBASE_AUTH_DOMAIN=
-
-# Cloudflare
-# https://dash.cloudflare.com/
-# https://developers.cloudflare.com/api/tokens/create
-CLOUDFLARE_ACCOUNT_ID=
-CLOUDFLARE_ZONE_ID=
-CLOUDFLARE_API_TOKEN=
-
-# Google Analytics (v4)
-# https://console.firebase.google.com/
-# https://firebase.google.com/docs/analytics/get-started?platform=web
-GA_MEASUREMENT_ID=G-XXXXXXXX
-
-# SendGrid
-# https://app.sendgrid.com/settings/api_keys
-SENDGRID_API_KEY=xxxxx
-FROM_EMAIL=hello@example.com
diff --git a/package.json b/package.json
index 5654c0e24..200005e21 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,6 @@
],
"scripts": {
"postinstall": "husky install && node ./scripts/postinstall.js",
- "update-schema": "node ./scripts/update-schema.js",
"start": "yarn workspace app start",
"lint": "eslint --cache --report-unused-disable-directives .",
"test": "vitest",
diff --git a/scripts/package.json b/scripts/package.json
index a4852972b..cec42516d 100644
--- a/scripts/package.json
+++ b/scripts/package.json
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"dependencies": {
- "envars": "^1.0.2",
+ "dotenv": "^16.3.1",
"execa": "^8.0.1",
"get-port": "^7.0.0",
"got": "^14.0.0",
diff --git a/scripts/update-schema.js b/scripts/update-schema.js
deleted file mode 100644
index 39856edd4..000000000
--- a/scripts/update-schema.js
+++ /dev/null
@@ -1,29 +0,0 @@
-/* SPDX-FileCopyrightText: 2014-present Kriasoft */
-/* SPDX-License-Identifier: MIT */
-
-import envars from "envars";
-import { got } from "got";
-import { buildClientSchema, getIntrospectionQuery, printSchema } from "graphql";
-import { format } from "prettier";
-import { argv, fs, path } from "zx";
-
-// Load the environment variables (API_ORIGIN, etc.)
-envars.config({ env: argv.env ?? "local" });
-const schemaURL = `${process.env.API_ORIGIN}/api`;
-
-// Download and save GraphQL API schema
-got
- .post(schemaURL, { json: { query: getIntrospectionQuery() } })
- .json()
- .then((res) => {
- const schema = buildClientSchema(res.data);
- const filename = path.resolve(__dirname, "../schema.graphql");
- let output = printSchema(schema, { commentDescriptions: true });
- output = format(output, { parser: "graphql" });
- fs.writeFileSync(filename, output, { encoding: "utf-8" });
- console.log(`Saved ${schemaURL} to ${path.basename(filename)}`);
- })
- .catch((err) => {
- console.error(err.stack);
- process.exitCode = 1;
- });
diff --git a/scripts/utils.js b/scripts/utils.js
index d4f96bd2d..35f78ec64 100644
--- a/scripts/utils.js
+++ b/scripts/utils.js
@@ -1,12 +1,12 @@
/* SPDX-FileCopyrightText: 2014-present Kriasoft */
/* SPDX-License-Identifier: MIT */
-import envars from "envars";
+import { configDotenv } from "dotenv";
import { template } from "lodash-es";
import { readFileSync } from "node:fs";
import fs from "node:fs/promises";
import { dirname, resolve } from "node:path";
-import { fileURLToPath } from "node:url";
+import { URL, fileURLToPath } from "node:url";
import { parse as parseToml } from "toml";
import { $ } from "zx";
@@ -44,7 +44,12 @@ export function getArgs() {
* Load environment variables used in the Cloudflare Worker.
*/
export function getCloudflareBindings(file = "wrangler.toml", envName) {
- const env = envars.config({ cwd: envDir, env: envName });
+ const envDir = fileURLToPath(new URL("..", import.meta.url));
+
+ configDotenv({ path: resolve(envDir, `.env.${envName}.local`) });
+ configDotenv({ path: resolve(envDir, `.env.local`) });
+ configDotenv({ path: resolve(envDir, `.env`) });
+
let config = parseToml(readFileSync(file, "utf-8"));
return {
@@ -52,15 +57,18 @@ export function getCloudflareBindings(file = "wrangler.toml", envName) {
GOOGLE_CLOUD_CREDENTIALS: process.env.GOOGLE_CLOUD_CREDENTIALS,
...JSON.parse(JSON.stringify(config.vars), (key, value) => {
return typeof value === "string"
- ? value.replace(/\$\{?([\w]+)\}?/g, (_, key) => env[key])
+ ? value.replace(/\$\{?([\w]+)\}?/g, (_, key) => process.env[key])
: value;
}),
};
}
export async function readWranglerConfig(file, envName = "test") {
- // Load environment variables from `env/*.env` file(s)
- envars.config({ cwd: resolve(rootDir, "env"), env: envName });
+ const envDir = fileURLToPath(new URL("..", import.meta.url));
+
+ configDotenv({ path: resolve(envDir, `.env.${envName}.local`) });
+ configDotenv({ path: resolve(envDir, `.env.local`) });
+ configDotenv({ path: resolve(envDir, `.env`) });
// Load Wrangler CLI configuration file
let config = parseToml(await fs.readFile(file, "utf-8"));
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 5925f4182..c5428593d 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -27,7 +27,7 @@
/* Modules */
"module": "ESNext", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
- "moduleResolution": "bundler", /* Specify how TypeScript looks up a file from a given module specifier. */
+ "moduleResolution": "Bundler", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
diff --git a/yarn.lock b/yarn.lock
index 15972cde1..0a46864a0 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1758,7 +1758,7 @@ __metadata:
languageName: node
linkType: hard
-"@mui/base@npm:5.0.0-beta.25":
+"@mui/base@npm:5.0.0-beta.25, @mui/base@npm:^5.0.0-beta.25":
version: 5.0.0-beta.25
resolution: "@mui/base@npm:5.0.0-beta.25"
dependencies:
@@ -1803,6 +1803,35 @@ __metadata:
languageName: node
linkType: hard
+"@mui/joy@npm:^5.0.0-beta.16":
+ version: 5.0.0-beta.16
+ resolution: "@mui/joy@npm:5.0.0-beta.16"
+ dependencies:
+ "@babel/runtime": "npm:^7.23.4"
+ "@mui/base": "npm:5.0.0-beta.25"
+ "@mui/core-downloads-tracker": "npm:^5.14.19"
+ "@mui/system": "npm:^5.14.19"
+ "@mui/types": "npm:^7.2.10"
+ "@mui/utils": "npm:^5.14.19"
+ clsx: "npm:^2.0.0"
+ prop-types: "npm:^15.8.1"
+ peerDependencies:
+ "@emotion/react": ^11.5.0
+ "@emotion/styled": ^11.3.0
+ "@types/react": ^17.0.0 || ^18.0.0
+ react: ^17.0.0 || ^18.0.0
+ react-dom: ^17.0.0 || ^18.0.0
+ peerDependenciesMeta:
+ "@emotion/react":
+ optional: true
+ "@emotion/styled":
+ optional: true
+ "@types/react":
+ optional: true
+ checksum: af082ffce30f562a4525bcd0d790eaec0bc638a76f9854482ceea28b819ef80058d26eabdeab80d4d5a9899e9220b95757a95d4118ecec971f7913e42e3391d1
+ languageName: node
+ linkType: hard
+
"@mui/lab@npm:^5.0.0-alpha.154":
version: 5.0.0-alpha.154
resolution: "@mui/lab@npm:5.0.0-alpha.154"
@@ -2435,14 +2464,14 @@ __metadata:
languageName: node
linkType: hard
-"@types/react@npm:*, @types/react@npm:^18.2.40":
- version: 18.2.40
- resolution: "@types/react@npm:18.2.40"
+"@types/react@npm:*, @types/react@npm:^18.2.41":
+ version: 18.2.41
+ resolution: "@types/react@npm:18.2.41"
dependencies:
"@types/prop-types": "npm:*"
"@types/scheduler": "npm:*"
csstype: "npm:^3.0.2"
- checksum: 323c319461482ad17b4813fec8641a4167361f42164757b64dbe27db379ae8fd98f3811a301f4abce0c17ce53a83c549db3ba3008e54d2a422a0656bddd72440
+ checksum: 31a498a56ad3e825ae13799355fe49042c0cdbbe6f40003f39b6b9cf847ba1669393c22ba60e97b1072cf1c002b15432082cdd17e47c948430bdc1f0864829b9
languageName: node
linkType: hard
@@ -2829,22 +2858,25 @@ __metadata:
"@emotion/babel-plugin": "npm:^11.11.0"
"@emotion/react": "npm:^11.11.1"
"@emotion/styled": "npm:^11.11.0"
+ "@mui/base": "npm:^5.0.0-beta.25"
"@mui/icons-material": "npm:^5.14.19"
+ "@mui/joy": "npm:^5.0.0-beta.16"
"@mui/lab": "npm:^5.0.0-alpha.154"
"@mui/material": "npm:^5.14.19"
"@types/node": "npm:^20.10.2"
- "@types/react": "npm:^18.2.40"
+ "@types/react": "npm:^18.2.41"
"@types/react-dom": "npm:^18.2.17"
"@vitejs/plugin-react": "npm:^4.2.0"
envars: "npm:^1.0.2"
firebase: "npm:^10.7.0"
happy-dom: "npm:^12.10.3"
+ jotai: "npm:^2.6.0"
+ jotai-effect: "npm:^0.2.3"
localforage: "npm:^1.10.0"
notistack: "npm:^3.0.1"
react: "npm:^18.2.0"
react-dom: "npm:^18.2.0"
react-router-dom: "npm:^6.20.1"
- recoil: "npm:^0.7.7"
typescript: "npm:~5.3.2"
vite: "npm:~5.0.4"
vitest: "npm:~0.34.6"
@@ -5270,13 +5302,6 @@ __metadata:
languageName: node
linkType: hard
-"hamt_plus@npm:1.0.2":
- version: 1.0.2
- resolution: "hamt_plus@npm:1.0.2"
- checksum: 3680a1820b8e03c79e5977edb7e1ccb48b9db853f5a1fbee2d3b3615f8dded7fa276e158f52d3eea8cb6192a27b2622ae2112e197be7602a8f99814793dc096f
- languageName: node
- linkType: hard
-
"happy-dom@npm:^10.11.2":
version: 10.11.2
resolution: "happy-dom@npm:10.11.2"
@@ -5919,6 +5944,30 @@ __metadata:
languageName: node
linkType: hard
+"jotai-effect@npm:^0.2.3":
+ version: 0.2.3
+ resolution: "jotai-effect@npm:0.2.3"
+ peerDependencies:
+ jotai: ">=2.4.3"
+ checksum: 3c99923fa6684231920ff34c15d42e4fc06e6dabe9596d6ac480eb35153ef3119c7682077370654b289aafb39b6e9a42b72f410cfda92f40c856af1b4598c4ba
+ languageName: node
+ linkType: hard
+
+"jotai@npm:^2.6.0":
+ version: 2.6.0
+ resolution: "jotai@npm:2.6.0"
+ peerDependencies:
+ "@types/react": ">=17.0.0"
+ react: ">=17.0.0"
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ react:
+ optional: true
+ checksum: 7e635f4f5052582095ed3949aa46f26213b10237bc4876db242c936f0f4d4272129727d7419e4fd5b1b83fa55b7ad737575453116a955dcc6b3a26cba14591f2
+ languageName: node
+ linkType: hard
+
"js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0":
version: 4.0.0
resolution: "js-tokens@npm:4.0.0"
@@ -7331,22 +7380,6 @@ __metadata:
languageName: node
linkType: hard
-"recoil@npm:^0.7.7":
- version: 0.7.7
- resolution: "recoil@npm:0.7.7"
- dependencies:
- hamt_plus: "npm:1.0.2"
- peerDependencies:
- react: ">=16.13.1"
- peerDependenciesMeta:
- react-dom:
- optional: true
- react-native:
- optional: true
- checksum: 4ac9dfeddda2795f213b14290de09767b04fb98f1a760bcabe796c53830229f51de63a02cf54c18991c557f25cfeb9571a129ae0a19d734bd98cfc45eebbc82d
- languageName: node
- linkType: hard
-
"reflect.getprototypeof@npm:^1.0.4":
version: 1.0.4
resolution: "reflect.getprototypeof@npm:1.0.4"
@@ -7701,7 +7734,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "scripts@workspace:scripts"
dependencies:
- envars: "npm:^1.0.2"
+ dotenv: "npm:^16.3.1"
execa: "npm:^8.0.1"
get-port: "npm:^7.0.0"
got: "npm:^14.0.0"
@@ -8352,9 +8385,9 @@ __metadata:
linkType: hard
"type-fest@npm:^4.2.0":
- version: 4.8.2
- resolution: "type-fest@npm:4.8.2"
- checksum: 86ea9b4c2a1784af7708b837948bd0b4c22d1b1bc15d95e0366213b983e802c09a76eccd56566e91ee5b7c961238b118fd256a7a323e7d79dc302773d40c99d2
+ version: 4.8.3
+ resolution: "type-fest@npm:4.8.3"
+ checksum: 90e440347c542282b0a92bb181fb30af529be6d6820dd7ec6141309f2ca143855a8fbca18969623b19bc15a3dcce6000af19f97cae81a39fbd2638c15a06d078
languageName: node
linkType: hard