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:
@ -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>
|
||||
|
@ -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])
|
||||
|
||||
|
@ -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 />,
|
||||
},
|
||||
])
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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 {
|
||||
|
@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -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', '')
|
||||
)}`}
|
||||
|
Reference in New Issue
Block a user