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", + " <${2} sx={{ ...sx }} {...other}>$0", " );", "}", "", - "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", + " );", + "}", + "" + ], + "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 ( + { + scheme.setMode(mode); + }} + selected={scheme.mode === mode} + > + + {mode === "light" || + (mode !== "dark" && scheme.systemMode === "light") ? ( + + ) : ( + + )} + + + {mode === "light" + ? "Light theme" + : mode === "dark" + ? "Dark theme" + : "Device default"} + + + ); +}); + +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 ( + + )} + + ); +} + +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 ( - - - state.onClose?.(event, "backdropClick")} - children={} - /> - - - - {error && ( - - {linkTo - ? `There is an existing account with the same email (${linkTo.email}). Would you like to link it?` - : error.message} - - )} - - - {signInMethods.map((method) => ( - - ))} - - - - ); -} - -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 && ( - - ); -} - -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 ( - - - } /> - - - - - } /> - - - - - - } /> - - - - {/* Copyright and links to legal documents */} - - x.palette.grey[500], - paddingTop: (x) => x.spacing(0.5), - paddingBottom: (x) => x.spacing(0.5), - fontSize: "0.75rem", - }} - > - © 2021 Company Name - β€’ - - β€’ - - - - ); -} - -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. - - )} - -
- {state.otpSent ? ( - - ) : ( - - )} - - - - ); -} -``` - -## 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