Keep App component loaded while navigating (#247)

* Make /settings not throw away App component

* Make App not reload for Onboarding

* Close sidebar when navigating to /settings

* Use centralized constants for route pathnames

* Clean up a few stray raw path literals
This commit is contained in:
Frank Noirot
2023-08-10 13:30:32 -04:00
committed by GitHub
parent dbb94d7e95
commit 3a93839a2d
10 changed files with 163 additions and 97 deletions

View File

@ -13,7 +13,7 @@
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<title>KittyCAD Modeling App</title> <title>KittyCAD Modeling App</title>
</head> </head>
<body> <body class="body-bg">
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="h-screen overflow-y-auto"></div> <div id="root" class="h-screen overflow-y-auto"></div>
<script type="module" src="/src/index.tsx"></script> <script type="module" src="/src/index.tsx"></script>

View File

@ -6,6 +6,7 @@ import { useNavigate } from 'react-router-dom'
import { useEffect } from 'react' import { useEffect } from 'react'
import { isTauri } from './lib/isTauri' import { isTauri } from './lib/isTauri'
import Loading from './components/Loading' import Loading from './components/Loading'
import { paths } from './Router'
// Wrapper around protected routes, used in src/Router.tsx // Wrapper around protected routes, used in src/Router.tsx
export const Auth = ({ children }: React.PropsWithChildren) => { export const Auth = ({ children }: React.PropsWithChildren) => {
@ -27,7 +28,7 @@ export const Auth = ({ children }: React.PropsWithChildren) => {
(isTauri() && !token) || (isTauri() && !token) ||
(!isTauri() && !isLoading && !(user && 'id' in user)) (!isTauri() && !isLoading && !(user && 'id' in user))
) { ) {
navigate('/signin') navigate(paths.SIGN_IN)
} }
}, [user, token, navigate, isLoading]) }, [user, token, navigate, isLoading])

View File

@ -1,52 +1,84 @@
import { App } from './App' import { App } from './App'
import { createBrowserRouter, redirect, RouterProvider } from 'react-router-dom' import {
createBrowserRouter,
Outlet,
redirect,
RouterProvider,
} from 'react-router-dom'
import { ErrorPage } from './components/ErrorPage' import { ErrorPage } from './components/ErrorPage'
import { Settings } from './routes/Settings' import { Settings } from './routes/Settings'
import Onboarding, { onboardingRoutes } from './routes/Onboarding' import Onboarding, {
onboardingRoutes,
onboardingPaths,
} from './routes/Onboarding'
import SignIn from './routes/SignIn' import SignIn from './routes/SignIn'
import { Auth } from './Auth' import { Auth } from './Auth'
const prependRoutes =
(routesObject: Record<string, string>) => (prepend: string) => {
return Object.fromEntries(
Object.entries(routesObject).map(([constName, path]) => [
constName,
prepend + path,
])
)
}
export const paths = {
INDEX: '/',
SETTINGS: '/settings',
SIGN_IN: '/signin',
ONBOARDING: prependRoutes(onboardingPaths)(
'/onboarding/'
) as typeof onboardingPaths,
}
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
path: '/', path: paths.INDEX,
element: ( element: (
<Auth> <Auth>
<Outlet />
<App /> <App />
</Auth> </Auth>
), ),
errorElement: <ErrorPage />, errorElement: <ErrorPage />,
loader: () => { loader: ({ request }) => {
const store = localStorage.getItem('store') const store = localStorage.getItem('store')
if (store === null) { if (store === null) {
return redirect('/onboarding') return redirect(paths.ONBOARDING.INDEX)
} else { } else {
const status = JSON.parse(store).state.onboardingStatus const status = JSON.parse(store).state.onboardingStatus || ''
if (status !== 'done' && status !== 'dismissed') { const notEnRouteToOnboarding =
return redirect('/onboarding/' + status) !request.url.includes(paths.ONBOARDING.INDEX) &&
request.method === 'GET'
// '' is the initial state, 'done' and 'dismissed' are the final states
const hasValidOnboardingStatus =
(status !== undefined && status.length === 0) ||
!(status === 'done' || status === 'dismissed')
const shouldRedirectToOnboarding =
notEnRouteToOnboarding && hasValidOnboardingStatus
if (shouldRedirectToOnboarding) {
return redirect(paths.ONBOARDING.INDEX + status)
} }
} }
return null return null
}, },
children: [
{
path: paths.SETTINGS,
element: <Settings />,
},
{
path: paths.ONBOARDING.INDEX,
element: <Onboarding />,
children: onboardingRoutes,
},
],
}, },
{ {
path: '/settings', path: paths.SIGN_IN,
element: (
<Auth>
<Settings />
</Auth>
),
},
{
path: '/onboarding',
element: (
<Auth>
<Onboarding />
</Auth>
),
children: onboardingRoutes,
},
{
path: '/signin',
element: <SignIn />, element: <SignIn />,
}, },
]) ])

View File

@ -1,6 +1,7 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { ActionIcon, ActionIconProps } from './ActionIcon' import { ActionIcon, ActionIconProps } from './ActionIcon'
import React from 'react' import React from 'react'
import { paths } from '../Router'
interface ActionButtonProps extends React.PropsWithChildren { interface ActionButtonProps extends React.PropsWithChildren {
icon?: ActionIconProps icon?: ActionIconProps
@ -17,7 +18,7 @@ export const ActionButton = ({
icon, icon,
className, className,
onClick, onClick,
to = '/', to = paths.INDEX,
Element = 'button', Element = 'button',
children, children,
...props ...props

View File

@ -2,6 +2,7 @@ import { Link } from 'react-router-dom'
import { Toolbar } from '../Toolbar' import { Toolbar } from '../Toolbar'
import { useStore } from '../useStore' import { useStore } from '../useStore'
import UserSidebarMenu from './UserSidebarMenu' import UserSidebarMenu from './UserSidebarMenu'
import { paths } from '../Router'
interface AppHeaderProps extends React.PropsWithChildren { interface AppHeaderProps extends React.PropsWithChildren {
showToolbar?: boolean showToolbar?: boolean
@ -24,7 +25,7 @@ export const AppHeader = ({
className className
} }
> >
<Link to="/"> <Link to={paths.INDEX}>
<img <img
src="/kitt-arcade-winking.svg" src="/kitt-arcade-winking.svg"
alt="KittyCAD App" alt="KittyCAD App"

View File

@ -4,6 +4,7 @@ import { ActionButton } from './ActionButton'
import { faBars, faGear, faSignOutAlt } from '@fortawesome/free-solid-svg-icons' import { faBars, faGear, faSignOutAlt } from '@fortawesome/free-solid-svg-icons'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useState } from 'react' import { useState } from 'react'
import { paths } from '../Router'
const UserSidebarMenu = ({ user }: { user?: User }) => { const UserSidebarMenu = ({ user }: { user?: User }) => {
const displayedName = getDisplayName(user) const displayedName = getDisplayName(user)
@ -58,61 +59,72 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
<Popover.Overlay className="fixed z-40 inset-0 bg-chalkboard-110/50" /> <Popover.Overlay className="fixed z-40 inset-0 bg-chalkboard-110/50" />
<Popover.Panel className="fixed inset-0 left-auto z-50 w-64 bg-chalkboard-10 dark:bg-chalkboard-100 border border-liquid-100 shadow-md rounded-l-lg"> <Popover.Panel className="fixed inset-0 left-auto z-50 w-64 bg-chalkboard-10 dark:bg-chalkboard-100 border border-liquid-100 shadow-md rounded-l-lg">
{user && ( {({ close }) => (
<div className="flex items-center gap-4 px-4 py-3 bg-liquid-100"> <>
{user.image && !imageLoadFailed && ( {user && (
<div className="rounded-full shadow-inner overflow-hidden"> <div className="flex items-center gap-4 px-4 py-3 bg-liquid-100">
<img {user.image && !imageLoadFailed && (
src={user.image} <div className="rounded-full shadow-inner overflow-hidden">
alt={user.name || ''} <img
className="h-8 w-8" src={user.image}
referrerPolicy="no-referrer" alt={user.name || ''}
onError={() => setImageLoadFailed(true)} className="h-8 w-8"
/> referrerPolicy="no-referrer"
onError={() => setImageLoadFailed(true)}
/>
</div>
)}
<div>
<p
className="m-0 text-liquid-10 text-mono"
data-testid="username"
>
{displayedName || ''}
</p>
{displayedName !== user.email && (
<p
className="m-0 text-liquid-40 text-xs"
data-testid="email"
>
{user.email}
</p>
)}
</div>
</div> </div>
)} )}
<div className="p-4 flex flex-col gap-2">
<div> <ActionButton
<p icon={{ icon: faGear }}
className="m-0 text-liquid-10 text-mono" className="border-transparent dark:border-transparent dark:hover:border-liquid-60"
data-testid="username" onClick={() => {
// since /settings is a nested route the sidebar doesn't close
// automatically when navigating to it
close()
navigate(paths.SETTINGS)
}}
> >
{displayedName || ''} Settings
</p> </ActionButton>
{displayedName !== user.email && ( <ActionButton
<p className="m-0 text-liquid-40 text-xs" data-testid="email"> Element="button"
{user.email} onClick={() => {
</p> setToken('')
)} navigate(paths.SIGN_IN)
}}
icon={{
icon: faSignOutAlt,
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="border-transparent dark:border-transparent hover:border-destroy-40 dark:hover:border-destroy-60"
>
Sign out
</ActionButton>
</div> </div>
</div> </>
)} )}
<div className="p-4 flex flex-col gap-2">
<ActionButton
Element="link"
icon={{ icon: faGear }}
to="/settings"
className="border-transparent dark:border-transparent dark:hover:border-liquid-60"
>
Settings
</ActionButton>
<ActionButton
Element="button"
onClick={() => {
setToken('')
navigate('/signin')
}}
icon={{
icon: faSignOutAlt,
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="border-transparent dark:border-transparent hover:border-destroy-40 dark:hover:border-destroy-60"
>
Sign out
</ActionButton>
</div>
</Popover.Panel> </Popover.Panel>
</Popover> </Popover>
) )

View File

@ -11,15 +11,24 @@ body {
sans-serif; sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
@apply text-chalkboard-110 bg-chalkboard-10; @apply text-chalkboard-110;
overflow: hidden; overflow: hidden;
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--color-chalkboard-20) var(--color-chalkboard-40); scrollbar-color: var(--color-chalkboard-20) var(--color-chalkboard-40);
} }
.body-bg {
@apply bg-chalkboard-10;
}
.body-bg.dark,
.dark .body-bg {
@apply bg-chalkboard-100;
}
body.dark { body.dark {
scrollbar-color: var(--color-chalkboard-70) var(--color-chalkboard-90); scrollbar-color: var(--color-chalkboard-70) var(--color-chalkboard-90);
@apply bg-chalkboard-100 text-chalkboard-10; @apply text-chalkboard-10;
} }
::-webkit-scrollbar { ::-webkit-scrollbar {

View File

@ -1,29 +1,36 @@
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { Outlet, useNavigate } from 'react-router-dom' import { Outlet, useNavigate } from 'react-router-dom'
import { useStore } from '../../useStore' import { useStore } from '../../useStore'
import { App } from '../../App'
import Introduction from './Introduction' import Introduction from './Introduction'
import Units from './Units' import Units from './Units'
import Camera from './Camera' import Camera from './Camera'
import Sketching from './Sketching' import Sketching from './Sketching'
import { useCallback } from 'react' import { useCallback } from 'react'
import { paths } from '../../Router'
export const onboardingPaths = {
INDEX: '',
UNITS: 'units',
CAMERA: 'camera',
SKETCHING: 'sketching',
}
export const onboardingRoutes = [ export const onboardingRoutes = [
{ {
path: '', index: true,
element: <Introduction />, element: <Introduction />,
}, },
{ {
path: 'units', path: onboardingPaths.UNITS,
element: <Units />, element: <Units />,
}, },
{ {
path: 'camera', path: onboardingPaths.CAMERA,
element: <Camera />, element: <Camera />,
}, },
{ {
path: 'sketching', path: onboardingPaths.SKETCHING,
element: <Sketching />, element: <Sketching />,
}, },
] ]
@ -48,7 +55,7 @@ export function useDismiss() {
return useCallback(() => { return useCallback(() => {
setOnboardingStatus('dismissed') setOnboardingStatus('dismissed')
navigate('/') navigate(paths.INDEX)
}, [setOnboardingStatus, navigate]) }, [setOnboardingStatus, navigate])
} }
@ -59,7 +66,6 @@ const Onboarding = () => {
return ( return (
<> <>
<Outlet /> <Outlet />
<App />
</> </>
) )
} }

View File

@ -12,10 +12,11 @@ import { toast } from 'react-hot-toast'
import { Toggle } from '../components/Toggle/Toggle' import { Toggle } from '../components/Toggle/Toggle'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { paths } from '../Router'
export const Settings = () => { export const Settings = () => {
const navigate = useNavigate() const navigate = useNavigate()
useHotkeys('esc', () => navigate('/')) useHotkeys('esc', () => navigate(paths.INDEX))
const { const {
defaultDir, defaultDir,
setDefaultDir, setDefaultDir,
@ -50,7 +51,7 @@ export const Settings = () => {
async function handleDirectorySelection() { async function handleDirectorySelection() {
const newDirectory = await open({ const newDirectory = await open({
directory: true, directory: true,
defaultPath: (defaultDir.base || '') + (defaultDir.dir || '/'), defaultPath: (defaultDir.base || '') + (defaultDir.dir || paths.INDEX),
title: 'Choose a new default directory', title: 'Choose a new default directory',
}) })
@ -60,11 +61,11 @@ export const Settings = () => {
} }
return ( return (
<> <div className="body-bg fixed inset-0 z-40 overflow-auto">
<AppHeader showToolbar={false}> <AppHeader showToolbar={false}>
<ActionButton <ActionButton
Element="link" Element="link"
to="/" to={paths.INDEX}
icon={{ icon={{
icon: faXmark, icon: faXmark,
bgClassName: 'bg-destroy-80', bgClassName: 'bg-destroy-80',
@ -211,7 +212,7 @@ export const Settings = () => {
<ActionButton <ActionButton
onClick={() => { onClick={() => {
setOnboardingStatus('') setOnboardingStatus('')
navigate('/') navigate(paths.ONBOARDING.INDEX)
}} }}
icon={{ icon: faArrowRotateBack }} icon={{ icon: faArrowRotateBack }}
> >
@ -219,7 +220,7 @@ export const Settings = () => {
</ActionButton> </ActionButton>
</SettingsSection> </SettingsSection>
</div> </div>
</> </div>
) )
} }

View File

@ -6,6 +6,7 @@ import { invoke } from '@tauri-apps/api/tauri'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { VITE_KC_SITE_BASE_URL, VITE_KC_API_BASE_URL } from '../env' import { VITE_KC_SITE_BASE_URL, VITE_KC_API_BASE_URL } from '../env'
import { getSystemTheme } from '../lib/getSystemTheme' import { getSystemTheme } from '../lib/getSystemTheme'
import { paths } from '../Router'
const SignIn = () => { const SignIn = () => {
const navigate = useNavigate() const navigate = useNavigate()
@ -21,7 +22,7 @@ const SignIn = () => {
host: VITE_KC_API_BASE_URL, host: VITE_KC_API_BASE_URL,
}) })
setToken(token) setToken(token)
navigate('/') navigate(paths.INDEX)
} catch (error) { } catch (error) {
console.error('login button', error) console.error('login button', error)
} }
@ -69,7 +70,9 @@ const SignIn = () => {
) : ( ) : (
<ActionButton <ActionButton
Element="link" Element="link"
to={`${VITE_KC_SITE_BASE_URL}/signin?callbackUrl=${encodeURIComponent( to={`${VITE_KC_SITE_BASE_URL}${
paths.SIGN_IN
}?callbackUrl=${encodeURIComponent(
typeof window !== 'undefined' && typeof window !== 'undefined' &&
window.location.href.replace('signin', '') window.location.href.replace('signin', '')
)}`} )}`}