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" />
<title>KittyCAD Modeling App</title>
</head>
<body>
<body class="body-bg">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="h-screen overflow-y-auto"></div>
<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 { isTauri } from './lib/isTauri'
import Loading from './components/Loading'
import { paths } from './Router'
// Wrapper around protected routes, used in src/Router.tsx
export const Auth = ({ children }: React.PropsWithChildren) => {
@ -27,7 +28,7 @@ export const Auth = ({ children }: React.PropsWithChildren) => {
(isTauri() && !token) ||
(!isTauri() && !isLoading && !(user && 'id' in user))
) {
navigate('/signin')
navigate(paths.SIGN_IN)
}
}, [user, token, navigate, isLoading])

View File

@ -1,52 +1,84 @@
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 { Settings } from './routes/Settings'
import Onboarding, { onboardingRoutes } from './routes/Onboarding'
import Onboarding, {
onboardingRoutes,
onboardingPaths,
} from './routes/Onboarding'
import SignIn from './routes/SignIn'
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([
{
path: '/',
path: paths.INDEX,
element: (
<Auth>
<Outlet />
<App />
</Auth>
),
errorElement: <ErrorPage />,
loader: () => {
loader: ({ request }) => {
const store = localStorage.getItem('store')
if (store === null) {
return redirect('/onboarding')
return redirect(paths.ONBOARDING.INDEX)
} else {
const status = JSON.parse(store).state.onboardingStatus
if (status !== 'done' && status !== 'dismissed') {
return redirect('/onboarding/' + status)
const status = JSON.parse(store).state.onboardingStatus || ''
const notEnRouteToOnboarding =
!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
},
children: [
{
path: paths.SETTINGS,
element: <Settings />,
},
{
path: paths.ONBOARDING.INDEX,
element: <Onboarding />,
children: onboardingRoutes,
},
],
},
{
path: '/settings',
element: (
<Auth>
<Settings />
</Auth>
),
},
{
path: '/onboarding',
element: (
<Auth>
<Onboarding />
</Auth>
),
children: onboardingRoutes,
},
{
path: '/signin',
path: paths.SIGN_IN,
element: <SignIn />,
},
])

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import { ActionButton } from './ActionButton'
import { faBars, faGear, faSignOutAlt } from '@fortawesome/free-solid-svg-icons'
import { useNavigate } from 'react-router-dom'
import { useState } from 'react'
import { paths } from '../Router'
const UserSidebarMenu = ({ user }: { user?: 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.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 && (
<div className="flex items-center gap-4 px-4 py-3 bg-liquid-100">
{user.image && !imageLoadFailed && (
<div className="rounded-full shadow-inner overflow-hidden">
<img
src={user.image}
alt={user.name || ''}
className="h-8 w-8"
referrerPolicy="no-referrer"
onError={() => setImageLoadFailed(true)}
/>
{({ close }) => (
<>
{user && (
<div className="flex items-center gap-4 px-4 py-3 bg-liquid-100">
{user.image && !imageLoadFailed && (
<div className="rounded-full shadow-inner overflow-hidden">
<img
src={user.image}
alt={user.name || ''}
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>
<p
className="m-0 text-liquid-10 text-mono"
data-testid="username"
<div className="p-4 flex flex-col gap-2">
<ActionButton
icon={{ icon: faGear }}
className="border-transparent dark:border-transparent dark:hover:border-liquid-60"
onClick={() => {
// since /settings is a nested route the sidebar doesn't close
// automatically when navigating to it
close()
navigate(paths.SETTINGS)
}}
>
{displayedName || ''}
</p>
{displayedName !== user.email && (
<p className="m-0 text-liquid-40 text-xs" data-testid="email">
{user.email}
</p>
)}
Settings
</ActionButton>
<ActionButton
Element="button"
onClick={() => {
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 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>
)

View File

@ -11,15 +11,24 @@ body {
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@apply text-chalkboard-110 bg-chalkboard-10;
@apply text-chalkboard-110;
overflow: hidden;
scrollbar-width: thin;
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 {
scrollbar-color: var(--color-chalkboard-70) var(--color-chalkboard-90);
@apply bg-chalkboard-100 text-chalkboard-10;
@apply text-chalkboard-10;
}
::-webkit-scrollbar {

View File

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

View File

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

View File

@ -6,6 +6,7 @@ import { invoke } from '@tauri-apps/api/tauri'
import { useNavigate } from 'react-router-dom'
import { VITE_KC_SITE_BASE_URL, VITE_KC_API_BASE_URL } from '../env'
import { getSystemTheme } from '../lib/getSystemTheme'
import { paths } from '../Router'
const SignIn = () => {
const navigate = useNavigate()
@ -21,7 +22,7 @@ const SignIn = () => {
host: VITE_KC_API_BASE_URL,
})
setToken(token)
navigate('/')
navigate(paths.INDEX)
} catch (error) {
console.error('login button', error)
}
@ -69,7 +70,9 @@ const SignIn = () => {
) : (
<ActionButton
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' &&
window.location.href.replace('signin', '')
)}`}