Home page in desktop, separate file support (#252)
* Bugfix: don't toast on every change of defaultDir * Refactor app to live under /file/:id * Stub out Tauri-only home page * home reads and writes blank files to defaultDir * Fix initial directory creation * Make file names editable * Refactor onboarding to use normal fns for load issues * Feature: load and write files to and from disk * Feature: Add file deletion, break out FileCard component * Fix settings close URLs to be relative, button types * Add filename and link to AppHeader * Style tweaks: scrollbar, header name, card size * Style: add header, empty state to Home * Refactor: load file in route loader * Move makePathRelative to lib to fix tests * Fix App test * Use '$nnn' default name scheme * Fix type error on ActionButton * Fix type error on ActionButton * @adamchalmers review * Fix merge mistake * Refactor: rename all things "file" to "project" * Feature: migrate to <project-name>/main.kcl setup * Fix tsc test * @Irev-Dev review part 1: renames and imports * @Irev-Dev review pt 2: simplify file list refresh * @Irev-Dev review pt 3: filter out non-projects * @Irev-review pt 4: folder conventions + home auth * Add sort functionality to new welcome page (#255) * Add todo for Sentry
This commit is contained in:
@ -32,6 +32,7 @@
|
|||||||
"react-router-dom": "^6.14.2",
|
"react-router-dom": "^6.14.2",
|
||||||
"sketch-helpers": "^0.0.4",
|
"sketch-helpers": "^0.0.4",
|
||||||
"swr": "^2.0.4",
|
"swr": "^2.0.4",
|
||||||
|
"tauri-plugin-fs-extra-api": "https://github.com/tauri-apps/tauri-plugin-fs-extra#v1",
|
||||||
"toml": "^3.0.0",
|
"toml": "^3.0.0",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"typescript": "^4.4.2",
|
"typescript": "^4.4.2",
|
||||||
|
13
src-tauri/Cargo.lock
generated
13
src-tauri/Cargo.lock
generated
@ -81,6 +81,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-fs-extra",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml 0.6.0",
|
"toml 0.6.0",
|
||||||
]
|
]
|
||||||
@ -3120,6 +3121,18 @@ dependencies = [
|
|||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-fs-extra"
|
||||||
|
version = "0.0.0"
|
||||||
|
source = "git+https://github.com/tauri-apps/plugins-workspace?branch=v1#7e58dc8502f654b99d51c087421f84ccc0e03119"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
|
@ -19,9 +19,10 @@ anyhow = "1"
|
|||||||
oauth2 = "4.4.1"
|
oauth2 = "4.4.1"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
tauri = { version = "1.3.0", features = ["dialog-all", "fs-all", "http-request", "shell-open", "shell-open-api"] }
|
tauri = { version = "1.3.0", features = [ "path-all", "dialog-all", "fs-all", "http-request", "shell-open", "shell-open-api"] }
|
||||||
tokio = { version = "1.29.1", features = ["time"] }
|
tokio = { version = "1.29.1", features = ["time"] }
|
||||||
toml = "0.6.0"
|
toml = "0.6.0"
|
||||||
|
tauri-plugin-fs-extra = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled.
|
# this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled.
|
||||||
|
@ -98,6 +98,7 @@ fn main() {
|
|||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![login, read_toml, read_txt_file])
|
.invoke_handler(tauri::generate_handler![login, read_toml, read_txt_file])
|
||||||
|
.plugin(tauri_plugin_fs_extra::init())
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
@ -22,9 +22,7 @@
|
|||||||
"save": true
|
"save": true
|
||||||
},
|
},
|
||||||
"fs": {
|
"fs": {
|
||||||
"scope": [
|
"scope": ["$HOME/**/*", "$APPDATA/**/*"],
|
||||||
"$HOME/**/*"
|
|
||||||
],
|
|
||||||
"all": true
|
"all": true
|
||||||
},
|
},
|
||||||
"http": {
|
"http": {
|
||||||
@ -37,6 +35,9 @@
|
|||||||
},
|
},
|
||||||
"shell": {
|
"shell": {
|
||||||
"open": true
|
"open": true
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"all": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import { App } from './App'
|
import { App } from './App'
|
||||||
|
import { describe, test, vi } from 'vitest'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
|
||||||
let listener: ((rect: any) => void) | undefined = undefined
|
let listener: ((rect: any) => void) | undefined = undefined
|
||||||
@ -12,7 +13,19 @@ let listener: ((rect: any) => void) | undefined = undefined
|
|||||||
disconnect() {}
|
disconnect() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
test('renders learn react link', () => {
|
describe('App tests', () => {
|
||||||
|
test('Renders the modeling app screen, including "Variables" pane.', () => {
|
||||||
|
vi.mock('react-router-dom', async () => {
|
||||||
|
const actual = (await vi.importActual('react-router-dom')) as Record<
|
||||||
|
string,
|
||||||
|
any
|
||||||
|
>
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useParams: () => ({ id: 'new' }),
|
||||||
|
useLoaderData: () => ({ code: null }),
|
||||||
|
}
|
||||||
|
})
|
||||||
render(
|
render(
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
@ -20,4 +33,7 @@ test('renders learn react link', () => {
|
|||||||
)
|
)
|
||||||
const linkElement = screen.getByText(/Variables/i)
|
const linkElement = screen.getByText(/Variables/i)
|
||||||
expect(linkElement).toBeInTheDocument()
|
expect(linkElement).toBeInTheDocument()
|
||||||
|
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
38
src/App.tsx
38
src/App.tsx
@ -43,8 +43,16 @@ import { useHotkeys } from 'react-hotkeys-hook'
|
|||||||
import { TEST } from './env'
|
import { TEST } from './env'
|
||||||
import { getNormalisedCoordinates } from './lib/utils'
|
import { getNormalisedCoordinates } from './lib/utils'
|
||||||
import { getSystemTheme } from './lib/getSystemTheme'
|
import { getSystemTheme } from './lib/getSystemTheme'
|
||||||
|
import { isTauri } from './lib/isTauri'
|
||||||
|
import { useLoaderData, useParams } from 'react-router-dom'
|
||||||
|
import { writeTextFile } from '@tauri-apps/api/fs'
|
||||||
|
import { FILE_EXT, PROJECT_ENTRYPOINT } from './lib/tauriFS'
|
||||||
|
import { IndexLoaderData } from './Router'
|
||||||
|
import { toast } from 'react-hot-toast'
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
|
const { code: loadedCode } = useLoaderData() as IndexLoaderData
|
||||||
|
const pathParams = useParams()
|
||||||
const streamRef = useRef<HTMLDivElement>(null)
|
const streamRef = useRef<HTMLDivElement>(null)
|
||||||
useHotKeyListener()
|
useHotKeyListener()
|
||||||
const {
|
const {
|
||||||
@ -151,9 +159,34 @@ export function App() {
|
|||||||
? 'opacity-40'
|
? 'opacity-40'
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
|
// Use file code loaded from disk
|
||||||
|
// on mount, and overwrite any locally-stored code
|
||||||
|
useEffect(() => {
|
||||||
|
if (isTauri() && loadedCode !== null) {
|
||||||
|
setCode(loadedCode)
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
// Clear code on unmount if in desktop app
|
||||||
|
if (isTauri()) {
|
||||||
|
setCode('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [loadedCode, setCode])
|
||||||
|
|
||||||
// const onChange = React.useCallback((value: string, viewUpdate: ViewUpdate) => {
|
// const onChange = React.useCallback((value: string, viewUpdate: ViewUpdate) => {
|
||||||
const onChange = (value: string, viewUpdate: ViewUpdate) => {
|
const onChange = (value: string, viewUpdate: ViewUpdate) => {
|
||||||
setCode(value)
|
setCode(value)
|
||||||
|
if (isTauri() && pathParams.id) {
|
||||||
|
// Save the file to disk
|
||||||
|
// Note that PROJECT_ENTRYPOINT is hardcoded until we support multiple files
|
||||||
|
writeTextFile(pathParams.id + '/' + PROJECT_ENTRYPOINT, value).catch(
|
||||||
|
(err) => {
|
||||||
|
// TODO: add Sentry per GH issue #254 (https://github.com/KittyCAD/modeling-app/issues/254)
|
||||||
|
console.error('error saving file', err)
|
||||||
|
toast.error('Error saving file, please check file permissions')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
if (editorView) {
|
if (editorView) {
|
||||||
editorView?.dispatch({ effects: addLineHighlight.of([0, 0]) })
|
editorView?.dispatch({ effects: addLineHighlight.of([0, 0]) })
|
||||||
}
|
}
|
||||||
@ -413,6 +446,11 @@ export function App() {
|
|||||||
paneOpacity +
|
paneOpacity +
|
||||||
(isMouseDownInStream ? ' pointer-events-none' : '')
|
(isMouseDownInStream ? ' pointer-events-none' : '')
|
||||||
}
|
}
|
||||||
|
filename={
|
||||||
|
pathParams.id
|
||||||
|
?.slice(pathParams.id.lastIndexOf('/') + 1)
|
||||||
|
.replace(FILE_EXT, '') || ''
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<ModalContainer />
|
<ModalContainer />
|
||||||
<Resizable
|
<Resizable
|
||||||
|
@ -13,6 +13,16 @@ import Onboarding, {
|
|||||||
} from './routes/Onboarding'
|
} from './routes/Onboarding'
|
||||||
import SignIn from './routes/SignIn'
|
import SignIn from './routes/SignIn'
|
||||||
import { Auth } from './Auth'
|
import { Auth } from './Auth'
|
||||||
|
import { isTauri } from './lib/isTauri'
|
||||||
|
import Home from './routes/Home'
|
||||||
|
import { FileEntry, readDir, readTextFile } from '@tauri-apps/api/fs'
|
||||||
|
import makeUrlPathRelative from './lib/makeUrlPathRelative'
|
||||||
|
import {
|
||||||
|
initializeProjectDirectory,
|
||||||
|
isProjectDirectory,
|
||||||
|
PROJECT_ENTRYPOINT,
|
||||||
|
} from './lib/tauriFS'
|
||||||
|
import { metadata, type Metadata } from 'tauri-plugin-fs-extra-api'
|
||||||
|
|
||||||
const prependRoutes =
|
const prependRoutes =
|
||||||
(routesObject: Record<string, string>) => (prepend: string) => {
|
(routesObject: Record<string, string>) => (prepend: string) => {
|
||||||
@ -26,16 +36,34 @@ const prependRoutes =
|
|||||||
|
|
||||||
export const paths = {
|
export const paths = {
|
||||||
INDEX: '/',
|
INDEX: '/',
|
||||||
|
HOME: '/home',
|
||||||
|
FILE: '/file',
|
||||||
SETTINGS: '/settings',
|
SETTINGS: '/settings',
|
||||||
SIGN_IN: '/signin',
|
SIGN_IN: '/signin',
|
||||||
ONBOARDING: prependRoutes(onboardingPaths)(
|
ONBOARDING: prependRoutes(onboardingPaths)(
|
||||||
'/onboarding/'
|
'/onboarding'
|
||||||
) as typeof onboardingPaths,
|
) as typeof onboardingPaths,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type IndexLoaderData = {
|
||||||
|
code: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectWithEntryPointMetadata = FileEntry & {
|
||||||
|
entrypoint_metadata: Metadata
|
||||||
|
}
|
||||||
|
export type HomeLoaderData = {
|
||||||
|
projects: ProjectWithEntryPointMetadata[]
|
||||||
|
}
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
path: paths.INDEX,
|
path: paths.INDEX,
|
||||||
|
loader: () =>
|
||||||
|
isTauri() ? redirect(paths.HOME) : redirect(paths.FILE + '/new'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: paths.FILE + '/:id',
|
||||||
element: (
|
element: (
|
||||||
<Auth>
|
<Auth>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
@ -43,7 +71,10 @@ const router = createBrowserRouter([
|
|||||||
</Auth>
|
</Auth>
|
||||||
),
|
),
|
||||||
errorElement: <ErrorPage />,
|
errorElement: <ErrorPage />,
|
||||||
loader: ({ request }) => {
|
loader: async ({
|
||||||
|
request,
|
||||||
|
params,
|
||||||
|
}): Promise<IndexLoaderData | Response> => {
|
||||||
const store = localStorage.getItem('store')
|
const store = localStorage.getItem('store')
|
||||||
if (store === null) {
|
if (store === null) {
|
||||||
return redirect(paths.ONBOARDING.INDEX)
|
return redirect(paths.ONBOARDING.INDEX)
|
||||||
@ -60,23 +91,72 @@ const router = createBrowserRouter([
|
|||||||
notEnRouteToOnboarding && hasValidOnboardingStatus
|
notEnRouteToOnboarding && hasValidOnboardingStatus
|
||||||
|
|
||||||
if (shouldRedirectToOnboarding) {
|
if (shouldRedirectToOnboarding) {
|
||||||
return redirect(paths.ONBOARDING.INDEX + status)
|
return redirect(makeUrlPathRelative(paths.ONBOARDING.INDEX) + status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
|
||||||
|
if (params.id && params.id !== 'new') {
|
||||||
|
// Note that PROJECT_ENTRYPOINT is hardcoded until we support multiple files
|
||||||
|
const code = await readTextFile(params.id + '/' + PROJECT_ENTRYPOINT)
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: '',
|
||||||
|
}
|
||||||
},
|
},
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: paths.SETTINGS,
|
path: makeUrlPathRelative(paths.SETTINGS),
|
||||||
element: <Settings />,
|
element: <Settings />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: paths.ONBOARDING.INDEX,
|
path: makeUrlPathRelative(paths.ONBOARDING.INDEX),
|
||||||
element: <Onboarding />,
|
element: <Onboarding />,
|
||||||
children: onboardingRoutes,
|
children: onboardingRoutes,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: paths.HOME,
|
||||||
|
element: (
|
||||||
|
<Auth>
|
||||||
|
<Outlet />
|
||||||
|
<Home />
|
||||||
|
</Auth>
|
||||||
|
),
|
||||||
|
loader: async () => {
|
||||||
|
if (!isTauri()) {
|
||||||
|
return redirect(paths.FILE + '/new')
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectDir = await initializeProjectDirectory()
|
||||||
|
const projectsNoMeta = (await readDir(projectDir.dir)).filter(
|
||||||
|
isProjectDirectory
|
||||||
|
)
|
||||||
|
const projects = await Promise.all(
|
||||||
|
projectsNoMeta.map(async (p) => ({
|
||||||
|
entrypoint_metadata: await metadata(
|
||||||
|
p.path + '/' + PROJECT_ENTRYPOINT
|
||||||
|
),
|
||||||
|
...p,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
projects,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: makeUrlPathRelative(paths.SETTINGS),
|
||||||
|
element: <Settings />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: paths.SIGN_IN,
|
path: paths.SIGN_IN,
|
||||||
element: <SignIn />,
|
element: <SignIn />,
|
||||||
|
@ -1,52 +1,92 @@
|
|||||||
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'
|
import { paths } from '../Router'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import type { LinkProps } from 'react-router-dom'
|
||||||
|
|
||||||
interface ActionButtonProps extends React.PropsWithChildren {
|
interface BaseActionButtonProps {
|
||||||
icon?: ActionIconProps
|
icon?: ActionIconProps
|
||||||
className?: string
|
className?: string
|
||||||
onClick?: () => void
|
|
||||||
to?: string
|
|
||||||
Element?:
|
|
||||||
| 'button'
|
|
||||||
| 'link'
|
|
||||||
| React.ComponentType<React.HTMLAttributes<HTMLButtonElement>>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ActionButton = ({
|
type ActionButtonAsButton = BaseActionButtonProps &
|
||||||
icon,
|
Omit<
|
||||||
className,
|
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
onClick,
|
keyof BaseActionButtonProps
|
||||||
to = paths.INDEX,
|
> & {
|
||||||
Element = 'button',
|
Element: 'button'
|
||||||
children,
|
}
|
||||||
...props
|
|
||||||
}: ActionButtonProps) => {
|
|
||||||
const classNames = `group mono text-base flex items-center gap-2 rounded-sm border border-chalkboard-40 dark:border-chalkboard-60 hover:border-liquid-40 dark:hover:bg-chalkboard-90 p-[3px] text-chalkboard-110 dark:text-chalkboard-10 hover:text-chalkboard-110 hover:dark:text-chalkboard-10 ${
|
|
||||||
icon ? 'pr-2' : 'px-2'
|
|
||||||
} ${className}`
|
|
||||||
|
|
||||||
if (Element === 'button') {
|
type ActionButtonAsLink = BaseActionButtonProps &
|
||||||
|
Omit<LinkProps, keyof BaseActionButtonProps> & {
|
||||||
|
Element: 'link'
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionButtonAsExternal = BaseActionButtonProps &
|
||||||
|
Omit<
|
||||||
|
React.AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||||
|
keyof BaseActionButtonProps
|
||||||
|
> & {
|
||||||
|
Element: 'externalLink'
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionButtonAsElement = BaseActionButtonProps &
|
||||||
|
Omit<React.HTMLAttributes<HTMLElement>, keyof BaseActionButtonProps> & {
|
||||||
|
Element: React.ComponentType<React.HTMLAttributes<HTMLButtonElement>>
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionButtonProps =
|
||||||
|
| ActionButtonAsButton
|
||||||
|
| ActionButtonAsLink
|
||||||
|
| ActionButtonAsExternal
|
||||||
|
| ActionButtonAsElement
|
||||||
|
|
||||||
|
export const ActionButton = (props: ActionButtonProps) => {
|
||||||
|
const classNames = `group mono text-base flex items-center gap-2 rounded-sm border border-chalkboard-40 dark:border-chalkboard-60 hover:border-liquid-40 dark:hover:bg-chalkboard-90 p-[3px] text-chalkboard-110 dark:text-chalkboard-10 hover:text-chalkboard-110 hover:dark:text-chalkboard-10 ${
|
||||||
|
props.icon ? 'pr-2' : 'px-2'
|
||||||
|
} ${props.className || ''}`
|
||||||
|
|
||||||
|
switch (props.Element) {
|
||||||
|
case 'button': {
|
||||||
|
// Note we have to destructure 'className' and 'Element' out of props
|
||||||
|
// because we don't want to pass them to the button element;
|
||||||
|
// the same is true for the other cases below.
|
||||||
|
const { Element, icon, children, className, ...rest } = props
|
||||||
return (
|
return (
|
||||||
<button onClick={onClick} className={classNames} {...props}>
|
<button className={classNames} {...rest}>
|
||||||
{icon && <ActionIcon {...icon} />}
|
{props.icon && <ActionIcon {...icon} />}
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
} else if (Element === 'link') {
|
}
|
||||||
|
case 'link': {
|
||||||
|
const { Element, to, icon, children, className, ...rest } = props
|
||||||
return (
|
return (
|
||||||
<Link to={to} className={classNames} {...props}>
|
<Link to={to || paths.INDEX} className={classNames} {...rest}>
|
||||||
{icon && <ActionIcon {...icon} />}
|
{icon && <ActionIcon {...icon} />}
|
||||||
{children}
|
{children}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
} else {
|
}
|
||||||
|
case 'externalLink': {
|
||||||
|
const { Element, icon, children, className, ...rest } = props
|
||||||
return (
|
return (
|
||||||
<Element onClick={onClick} className={classNames} {...props}>
|
<a className={classNames} {...rest}>
|
||||||
{icon && <ActionIcon {...icon} />}
|
{icon && <ActionIcon {...icon} />}
|
||||||
{children}
|
{children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
const { Element, icon, children, className, ...rest } = props
|
||||||
|
if (!Element) throw new Error('Element is required')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Element className={classNames} {...rest}>
|
||||||
|
{props.icon && <ActionIcon {...props.icon} />}
|
||||||
|
{children}
|
||||||
</Element>
|
</Element>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,14 +3,17 @@ import { Toolbar } from '../Toolbar'
|
|||||||
import { useStore } from '../useStore'
|
import { useStore } from '../useStore'
|
||||||
import UserSidebarMenu from './UserSidebarMenu'
|
import UserSidebarMenu from './UserSidebarMenu'
|
||||||
import { paths } from '../Router'
|
import { paths } from '../Router'
|
||||||
|
import { isTauri } from '../lib/isTauri'
|
||||||
|
|
||||||
interface AppHeaderProps extends React.PropsWithChildren {
|
interface AppHeaderProps extends React.PropsWithChildren {
|
||||||
showToolbar?: boolean
|
showToolbar?: boolean
|
||||||
|
filename?: string
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AppHeader = ({
|
export const AppHeader = ({
|
||||||
showToolbar = true,
|
showToolbar = true,
|
||||||
|
filename = '',
|
||||||
children,
|
children,
|
||||||
className = '',
|
className = '',
|
||||||
}: AppHeaderProps) => {
|
}: AppHeaderProps) => {
|
||||||
@ -25,13 +28,18 @@ export const AppHeader = ({
|
|||||||
className
|
className
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Link to={paths.INDEX}>
|
<Link
|
||||||
|
to={isTauri() ? paths.HOME : paths.INDEX}
|
||||||
|
className="flex items-center gap-4"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src="/kitt-arcade-winking.svg"
|
src="/kitt-arcade-winking.svg"
|
||||||
alt="KittyCAD App"
|
alt="KittyCAD App"
|
||||||
className="h-9 w-auto"
|
className="h-9 w-auto"
|
||||||
/>
|
/>
|
||||||
<span className="sr-only">KittyCAD App</span>
|
<span className="text-sm text-chalkboard-110 dark:text-chalkboard-20 min-w-max">
|
||||||
|
{isTauri() && filename ? filename : 'KittyCAD Modeling App'}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
{/* Toolbar if the context deems it */}
|
{/* Toolbar if the context deems it */}
|
||||||
{showToolbar && (
|
{showToolbar && (
|
||||||
|
@ -73,6 +73,7 @@ export const DebugPanel = ({ className, ...props }: CollapsiblePanelProps) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
engineCommandManager?.sendSceneCommand({
|
engineCommandManager?.sendSceneCommand({
|
||||||
type: 'modeling_cmd_req',
|
type: 'modeling_cmd_req',
|
||||||
|
@ -166,6 +166,7 @@ export const ExportButton = () => {
|
|||||||
</form>
|
</form>
|
||||||
<div className="flex justify-between mt-6">
|
<div className="flex justify-between mt-6">
|
||||||
<ActionButton
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
onClick={closeModal}
|
onClick={closeModal}
|
||||||
icon={{
|
icon={{
|
||||||
icon: faXmark,
|
icon: faXmark,
|
||||||
|
162
src/components/ProjectCard.tsx
Normal file
162
src/components/ProjectCard.tsx
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import { FormEvent, useState } from 'react'
|
||||||
|
import { type ProjectWithEntryPointMetadata, paths } from '../Router'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { ActionButton } from './ActionButton'
|
||||||
|
import {
|
||||||
|
faCheck,
|
||||||
|
faPenAlt,
|
||||||
|
faTrashAlt,
|
||||||
|
faX,
|
||||||
|
} from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { FILE_EXT } from '../lib/tauriFS'
|
||||||
|
import { Dialog } from '@headlessui/react'
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
|
|
||||||
|
function ProjectCard({
|
||||||
|
project,
|
||||||
|
handleRenameProject,
|
||||||
|
handleDeleteProject,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
project: ProjectWithEntryPointMetadata
|
||||||
|
handleRenameProject: (
|
||||||
|
e: FormEvent<HTMLFormElement>,
|
||||||
|
f: ProjectWithEntryPointMetadata
|
||||||
|
) => Promise<void>
|
||||||
|
handleDeleteProject: (f: ProjectWithEntryPointMetadata) => Promise<void>
|
||||||
|
}) {
|
||||||
|
useHotkeys('esc', () => setIsEditing(false))
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false)
|
||||||
|
|
||||||
|
function handleSave(e: FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleRenameProject(e, project).then(() => setIsEditing(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayedTime(date: Date) {
|
||||||
|
const startOfToday = new Date()
|
||||||
|
startOfToday.setHours(0, 0, 0, 0)
|
||||||
|
return date.getTime() < startOfToday.getTime()
|
||||||
|
? date.toLocaleDateString()
|
||||||
|
: date.toLocaleTimeString()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
{...props}
|
||||||
|
className="group relative min-h-[5em] p-1 rounded-sm border border-chalkboard-20 dark:border-chalkboard-90 hover:border-chalkboard-30 dark:hover:border-chalkboard-80"
|
||||||
|
>
|
||||||
|
{isEditing ? (
|
||||||
|
<form onSubmit={handleSave} className="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
className="dark:bg-chalkboard-80 dark:border-chalkboard-40 min-w-0 p-1"
|
||||||
|
type="text"
|
||||||
|
id="newProjectName"
|
||||||
|
name="newProjectName"
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="off"
|
||||||
|
defaultValue={project.name}
|
||||||
|
autoFocus={true}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1 items-center">
|
||||||
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
|
type="submit"
|
||||||
|
icon={{ icon: faCheck, size: 'sm' }}
|
||||||
|
className="!p-0"
|
||||||
|
></ActionButton>
|
||||||
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
|
icon={{ icon: faX, size: 'sm' }}
|
||||||
|
className="!p-0"
|
||||||
|
onClick={() => setIsEditing(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="p-1 flex flex-col gap-2">
|
||||||
|
<Link
|
||||||
|
to={`${paths.FILE}/${encodeURIComponent(project.path)}`}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{project.name?.replace(FILE_EXT, '')}
|
||||||
|
</Link>
|
||||||
|
<span className="text-chalkboard-40 dark:text-chalkboard-60 text-xs">
|
||||||
|
Edited {getDisplayedTime(project.entrypoint_metadata.modifiedAt)}
|
||||||
|
</span>
|
||||||
|
<div className="absolute bottom-2 right-2 flex gap-1 items-center opacity-0 group-hover:opacity-100 group-focus-within:opacity-100">
|
||||||
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
|
icon={{ icon: faPenAlt, size: 'sm' }}
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className="!p-0"
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
|
icon={{
|
||||||
|
icon: faTrashAlt,
|
||||||
|
size: 'sm',
|
||||||
|
bgClassName: 'bg-destroy-80 hover:bg-destroy-70',
|
||||||
|
iconClassName:
|
||||||
|
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10 dark:text-destroy-20 dark:group-hover:text-destroy-10 dark:hover:text-destroy-10',
|
||||||
|
}}
|
||||||
|
className="!p-0 hover:border-destroy-40 dark:hover:border-destroy-40"
|
||||||
|
onClick={() => setIsConfirmingDelete(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Dialog
|
||||||
|
open={isConfirmingDelete}
|
||||||
|
onClose={() => setIsConfirmingDelete(false)}
|
||||||
|
className="relative z-50"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-chalkboard-110/80 grid place-content-center">
|
||||||
|
<Dialog.Panel className="rounded p-4 bg-chalkboard-10 dark:bg-chalkboard-100 border border-destroy-80 max-w-2xl">
|
||||||
|
<Dialog.Title as="h2" className="text-2xl font-bold mb-4">
|
||||||
|
Delete File
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
This will permanently delete "{project.name || 'this file'}".
|
||||||
|
</Dialog.Description>
|
||||||
|
|
||||||
|
<p className="my-4">
|
||||||
|
Are you sure you want to delete "{project.name || 'this file'}
|
||||||
|
"? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
|
onClick={async () => {
|
||||||
|
await handleDeleteProject(project)
|
||||||
|
setIsConfirmingDelete(false)
|
||||||
|
}}
|
||||||
|
icon={{
|
||||||
|
icon: faTrashAlt,
|
||||||
|
bgClassName: 'bg-destroy-80',
|
||||||
|
iconClassName:
|
||||||
|
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10 dark:text-destroy-20 dark:group-hover:text-destroy-10 dark:hover:text-destroy-10',
|
||||||
|
}}
|
||||||
|
className="hover:border-destroy-40 dark:hover:border-destroy-40"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
|
onClick={() => setIsConfirmingDelete(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</Dialog.Panel>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectCard
|
@ -3,7 +3,8 @@ import { User } from '../useStore'
|
|||||||
import UserSidebarMenu from './UserSidebarMenu'
|
import UserSidebarMenu from './UserSidebarMenu'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
|
||||||
it("Renders user's name and email if available", () => {
|
describe('UserSidebarMenu tests', () => {
|
||||||
|
test("Renders user's name and email if available", () => {
|
||||||
const userWellFormed: User = {
|
const userWellFormed: User = {
|
||||||
id: '8675309',
|
id: '8675309',
|
||||||
name: 'Test User',
|
name: 'Test User',
|
||||||
@ -25,9 +26,9 @@ it("Renders user's name and email if available", () => {
|
|||||||
userWellFormed.name || ''
|
userWellFormed.name || ''
|
||||||
)
|
)
|
||||||
expect(screen.getByTestId('email')).toHaveTextContent(userWellFormed.email)
|
expect(screen.getByTestId('email')).toHaveTextContent(userWellFormed.email)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("Renders just the user's email if no name is available", () => {
|
test("Renders just the user's email if no name is available", () => {
|
||||||
const userNoName: User = {
|
const userNoName: User = {
|
||||||
id: '8675309',
|
id: '8675309',
|
||||||
email: 'kittycad.sidebar.test@example.com',
|
email: 'kittycad.sidebar.test@example.com',
|
||||||
@ -45,9 +46,9 @@ it("Renders just the user's email if no name is available", () => {
|
|||||||
fireEvent.click(screen.getByTestId('user-sidebar-toggle'))
|
fireEvent.click(screen.getByTestId('user-sidebar-toggle'))
|
||||||
|
|
||||||
expect(screen.getByTestId('username')).toHaveTextContent(userNoName.email)
|
expect(screen.getByTestId('username')).toHaveTextContent(userNoName.email)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Renders a menu button if no user avatar is available', () => {
|
test('Renders a menu button if no user avatar is available', () => {
|
||||||
const userNoAvatar: User = {
|
const userNoAvatar: User = {
|
||||||
id: '8675309',
|
id: '8675309',
|
||||||
name: 'Test User',
|
name: 'Test User',
|
||||||
@ -63,4 +64,5 @@ it('Renders a menu button if no user avatar is available', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(screen.getByTestId('user-sidebar-toggle')).toHaveTextContent('Menu')
|
expect(screen.getByTestId('user-sidebar-toggle')).toHaveTextContent('Menu')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@ -5,6 +5,7 @@ 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'
|
import { paths } from '../Router'
|
||||||
|
import makeUrlPathRelative from '../lib/makeUrlPathRelative'
|
||||||
|
|
||||||
const UserSidebarMenu = ({ user }: { user?: User }) => {
|
const UserSidebarMenu = ({ user }: { user?: User }) => {
|
||||||
const displayedName = getDisplayName(user)
|
const displayedName = getDisplayName(user)
|
||||||
@ -95,13 +96,14 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
|
|||||||
)}
|
)}
|
||||||
<div className="p-4 flex flex-col gap-2">
|
<div className="p-4 flex flex-col gap-2">
|
||||||
<ActionButton
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
icon={{ icon: faGear }}
|
icon={{ icon: faGear }}
|
||||||
className="border-transparent dark:border-transparent dark:hover:border-liquid-60"
|
className="border-transparent dark:border-transparent dark:hover:border-liquid-60"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// since /settings is a nested route the sidebar doesn't close
|
// since /settings is a nested route the sidebar doesn't close
|
||||||
// automatically when navigating to it
|
// automatically when navigating to it
|
||||||
close()
|
close()
|
||||||
navigate(paths.SETTINGS)
|
navigate(makeUrlPathRelative(paths.SETTINGS))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Settings
|
Settings
|
||||||
|
@ -32,7 +32,7 @@ body.dark {
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
@apply w-2 rounded-sm;
|
@apply w-2 h-2 rounded-sm;
|
||||||
@apply bg-chalkboard-20;
|
@apply bg-chalkboard-20;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
3
src/lib/makeUrlPathRelative.ts
Normal file
3
src/lib/makeUrlPathRelative.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export default function makeUrlPathRelative(path: string) {
|
||||||
|
return path.replace(/^\//, '')
|
||||||
|
}
|
48
src/lib/tauriFS.test.ts
Normal file
48
src/lib/tauriFS.test.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
MAX_PADDING,
|
||||||
|
getNextProjectIndex,
|
||||||
|
interpolateProjectNameWithIndex,
|
||||||
|
} from './tauriFS'
|
||||||
|
|
||||||
|
describe('Test file utility functions', () => {
|
||||||
|
it('interpolates a project name without an index', () => {
|
||||||
|
expect(interpolateProjectNameWithIndex('test', 1)).toBe('test')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('interpolates a project name with an index and no padding', () => {
|
||||||
|
expect(interpolateProjectNameWithIndex('test-$n', 2)).toBe('test-2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('interpolates a project name with an index and padding', () => {
|
||||||
|
expect(interpolateProjectNameWithIndex('test-$nnn', 12)).toBe('test-012')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('interpolates a project name with an index and max padding', () => {
|
||||||
|
expect(interpolateProjectNameWithIndex('test-$nnnnnnnnnnn', 3)).toBe(
|
||||||
|
`test-${'0'.repeat(MAX_PADDING)}3`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const testFiles = [
|
||||||
|
{
|
||||||
|
name: 'new-project-04.kcl',
|
||||||
|
path: '/projects/new-project-04.kcl',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'new-project-007.kcl',
|
||||||
|
path: '/projects/new-project-007.kcl',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'new-project-05.kcl',
|
||||||
|
path: '/projects/new-project-05.kcl',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'new-project-0.kcl',
|
||||||
|
path: '/projects/new-project-0.kcl',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
it('gets the correct next project index', () => {
|
||||||
|
expect(getNextProjectIndex('new-project-$n', testFiles)).toBe(8)
|
||||||
|
})
|
||||||
|
})
|
143
src/lib/tauriFS.ts
Normal file
143
src/lib/tauriFS.ts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import { FileEntry, createDir, exists, writeTextFile } from '@tauri-apps/api/fs'
|
||||||
|
import { documentDir } from '@tauri-apps/api/path'
|
||||||
|
import { useStore } from '../useStore'
|
||||||
|
import { isTauri } from './isTauri'
|
||||||
|
import { ProjectWithEntryPointMetadata } from '../Router'
|
||||||
|
import { metadata } from 'tauri-plugin-fs-extra-api'
|
||||||
|
|
||||||
|
const PROJECT_FOLDER = 'kittycad-modeling-projects'
|
||||||
|
export const FILE_EXT = '.kcl'
|
||||||
|
export const PROJECT_ENTRYPOINT = 'main' + FILE_EXT
|
||||||
|
const INDEX_IDENTIFIER = '$n' // $nn.. will pad the number with 0s
|
||||||
|
export const MAX_PADDING = 7
|
||||||
|
|
||||||
|
// Initializes the project directory and returns the path
|
||||||
|
export async function initializeProjectDirectory() {
|
||||||
|
if (!isTauri()) {
|
||||||
|
throw new Error(
|
||||||
|
'initializeProjectDirectory() can only be called from a Tauri app'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const { defaultDir: projectDir, setDefaultDir } = useStore.getState()
|
||||||
|
|
||||||
|
if (projectDir && projectDir.dir.length > 0) {
|
||||||
|
const dirExists = await exists(projectDir.dir)
|
||||||
|
if (!dirExists) {
|
||||||
|
await createDir(projectDir.dir, { recursive: true })
|
||||||
|
}
|
||||||
|
return projectDir
|
||||||
|
}
|
||||||
|
|
||||||
|
const appData = await documentDir()
|
||||||
|
|
||||||
|
const INITIAL_DEFAULT_DIR = {
|
||||||
|
dir: appData + PROJECT_FOLDER,
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultDirExists = await exists(INITIAL_DEFAULT_DIR.dir)
|
||||||
|
|
||||||
|
if (!defaultDirExists) {
|
||||||
|
await createDir(INITIAL_DEFAULT_DIR.dir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
setDefaultDir(INITIAL_DEFAULT_DIR)
|
||||||
|
return INITIAL_DEFAULT_DIR
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isProjectDirectory(fileOrDir: Partial<FileEntry>) {
|
||||||
|
return (
|
||||||
|
fileOrDir.children?.length &&
|
||||||
|
fileOrDir.children.some((child) => child.name === PROJECT_ENTRYPOINT)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates a new file in the default directory with the default project name
|
||||||
|
// Returns the path to the new file
|
||||||
|
export async function createNewProject(
|
||||||
|
path: string
|
||||||
|
): Promise<ProjectWithEntryPointMetadata> {
|
||||||
|
if (!isTauri) {
|
||||||
|
throw new Error('createNewProject() can only be called from a Tauri app')
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirExists = await exists(path)
|
||||||
|
if (!dirExists) {
|
||||||
|
await createDir(path, { recursive: true }).catch((err) => {
|
||||||
|
console.error('Error creating new directory:', err)
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeTextFile(path + '/' + PROJECT_ENTRYPOINT, '').catch((err) => {
|
||||||
|
console.error('Error creating new file:', err)
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
|
||||||
|
const m = await metadata(path)
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: path.slice(path.lastIndexOf('/') + 1),
|
||||||
|
path: path,
|
||||||
|
entrypoint_metadata: m,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: PROJECT_ENTRYPOINT,
|
||||||
|
path: path + '/' + PROJECT_ENTRYPOINT,
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// create a regex to match the project name
|
||||||
|
// replacing any instances of "$n" with a regex to match any number
|
||||||
|
function interpolateProjectName(projectName: string) {
|
||||||
|
const regex = new RegExp(
|
||||||
|
projectName.replace(getPaddedIdentifierRegExp(), '([0-9]+)')
|
||||||
|
)
|
||||||
|
return regex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the next available index for a project name
|
||||||
|
export function getNextProjectIndex(projectName: string, files: FileEntry[]) {
|
||||||
|
const regex = interpolateProjectName(projectName)
|
||||||
|
const matches = files.map((file) => file.name?.match(regex))
|
||||||
|
const indices = matches
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((match) => match![1])
|
||||||
|
.map(Number)
|
||||||
|
const maxIndex = Math.max(...indices, -1)
|
||||||
|
return maxIndex + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interpolates the project name with the next available index,
|
||||||
|
// padding the index with 0s if necessary
|
||||||
|
export function interpolateProjectNameWithIndex(
|
||||||
|
projectName: string,
|
||||||
|
index: number
|
||||||
|
) {
|
||||||
|
const regex = getPaddedIdentifierRegExp()
|
||||||
|
|
||||||
|
const matches = projectName.match(regex)
|
||||||
|
const padStartLength = Math.min(
|
||||||
|
matches !== null ? matches[1]?.length || 0 : 0,
|
||||||
|
MAX_PADDING
|
||||||
|
)
|
||||||
|
return projectName.replace(
|
||||||
|
regex,
|
||||||
|
index.toString().padStart(padStartLength + 1, '0')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function doesProjectNameNeedInterpolated(projectName: string) {
|
||||||
|
return projectName.includes(INDEX_IDENTIFIER)
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExpChars(string: string) {
|
||||||
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPaddedIdentifierRegExp() {
|
||||||
|
const escapedIdentifier = escapeRegExpChars(INDEX_IDENTIFIER)
|
||||||
|
return new RegExp(`${escapedIdentifier}(${escapedIdentifier.slice(-1)}*)`)
|
||||||
|
}
|
258
src/routes/Home.tsx
Normal file
258
src/routes/Home.tsx
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||||
|
import { readDir, removeDir, renameFile } from '@tauri-apps/api/fs'
|
||||||
|
import {
|
||||||
|
createNewProject,
|
||||||
|
getNextProjectIndex,
|
||||||
|
interpolateProjectNameWithIndex,
|
||||||
|
doesProjectNameNeedInterpolated,
|
||||||
|
isProjectDirectory,
|
||||||
|
PROJECT_ENTRYPOINT,
|
||||||
|
} from '../lib/tauriFS'
|
||||||
|
import { ActionButton } from '../components/ActionButton'
|
||||||
|
import {
|
||||||
|
faArrowDown,
|
||||||
|
faArrowUp,
|
||||||
|
faCircleDot,
|
||||||
|
faPlus,
|
||||||
|
} from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { useStore } from '../useStore'
|
||||||
|
import { toast } from 'react-hot-toast'
|
||||||
|
import { AppHeader } from '../components/AppHeader'
|
||||||
|
import ProjectCard from '../components/ProjectCard'
|
||||||
|
import { useLoaderData, useSearchParams } from 'react-router-dom'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { ProjectWithEntryPointMetadata, HomeLoaderData } from '../Router'
|
||||||
|
import Loading from '../components/Loading'
|
||||||
|
import { metadata } from 'tauri-plugin-fs-extra-api'
|
||||||
|
|
||||||
|
const DESC = ':desc'
|
||||||
|
|
||||||
|
// This route only opens in the Tauri desktop context for now,
|
||||||
|
// as defined in Router.tsx, so we can use the Tauri APIs and types.
|
||||||
|
const Home = () => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const sort = searchParams.get('sort_by') ?? 'modified:desc'
|
||||||
|
const { projects: loadedProjects } = useLoaderData() as HomeLoaderData
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [projects, setProjects] = useState(loadedProjects || [])
|
||||||
|
const { defaultDir, defaultProjectName } = useStore((s) => ({
|
||||||
|
defaultDir: s.defaultDir,
|
||||||
|
defaultProjectName: s.defaultProjectName,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const refreshProjects = useCallback(
|
||||||
|
async (projectDir = defaultDir) => {
|
||||||
|
const readProjects = (
|
||||||
|
await readDir(projectDir.dir, {
|
||||||
|
recursive: true,
|
||||||
|
})
|
||||||
|
).filter(isProjectDirectory)
|
||||||
|
|
||||||
|
const projectsWithMetadata = await Promise.all(
|
||||||
|
readProjects.map(async (p) => ({
|
||||||
|
entrypoint_metadata: await metadata(
|
||||||
|
p.path + '/' + PROJECT_ENTRYPOINT
|
||||||
|
),
|
||||||
|
...p,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
setProjects(projectsWithMetadata)
|
||||||
|
},
|
||||||
|
[defaultDir, setProjects]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshProjects(defaultDir).then(() => {
|
||||||
|
setIsLoading(false)
|
||||||
|
})
|
||||||
|
}, [setIsLoading, refreshProjects, defaultDir])
|
||||||
|
|
||||||
|
async function handleNewProject() {
|
||||||
|
let projectName = defaultProjectName
|
||||||
|
if (doesProjectNameNeedInterpolated(projectName)) {
|
||||||
|
const nextIndex = await getNextProjectIndex(defaultProjectName, projects)
|
||||||
|
projectName = interpolateProjectNameWithIndex(
|
||||||
|
defaultProjectName,
|
||||||
|
nextIndex
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await createNewProject(defaultDir.dir + '/' + projectName).catch((err) => {
|
||||||
|
console.error('Error creating project:', err)
|
||||||
|
toast.error('Error creating project')
|
||||||
|
})
|
||||||
|
|
||||||
|
await refreshProjects()
|
||||||
|
toast.success('Project created')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRenameProject(
|
||||||
|
e: FormEvent<HTMLFormElement>,
|
||||||
|
project: ProjectWithEntryPointMetadata
|
||||||
|
) {
|
||||||
|
const { newProjectName } = Object.fromEntries(
|
||||||
|
new FormData(e.target as HTMLFormElement)
|
||||||
|
)
|
||||||
|
if (newProjectName && project.name && newProjectName !== project.name) {
|
||||||
|
const dir = project.path?.slice(0, project.path?.lastIndexOf('/'))
|
||||||
|
await renameFile(project.path, dir + '/' + newProjectName).catch(
|
||||||
|
(err) => {
|
||||||
|
console.error('Error renaming project:', err)
|
||||||
|
toast.error('Error renaming project')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await refreshProjects()
|
||||||
|
toast.success('Project renamed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteProject(project: ProjectWithEntryPointMetadata) {
|
||||||
|
if (project.path) {
|
||||||
|
await removeDir(project.path, { recursive: true }).catch((err) => {
|
||||||
|
console.error('Error deleting project:', err)
|
||||||
|
toast.error('Error deleting project')
|
||||||
|
})
|
||||||
|
|
||||||
|
await refreshProjects()
|
||||||
|
toast.success('Project deleted')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSortIcon(sortBy: string) {
|
||||||
|
if (sort === sortBy) {
|
||||||
|
return faArrowUp
|
||||||
|
} else if (sort === sortBy + DESC) {
|
||||||
|
return faArrowDown
|
||||||
|
}
|
||||||
|
return faCircleDot
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextSearchParams(sortBy: string) {
|
||||||
|
if (sort === null || !sort)
|
||||||
|
return { sort_by: sortBy + (sortBy !== 'modified' ? DESC : '') }
|
||||||
|
if (sort.includes(sortBy) && !sort.includes(DESC)) return { sort_by: '' }
|
||||||
|
return {
|
||||||
|
sort_by: sortBy + (sort.includes(DESC) ? '' : DESC),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSortFunction(sortBy: string) {
|
||||||
|
const sortByName = (
|
||||||
|
a: ProjectWithEntryPointMetadata,
|
||||||
|
b: ProjectWithEntryPointMetadata
|
||||||
|
) => {
|
||||||
|
if (a.name && b.name) {
|
||||||
|
return sortBy.includes('desc')
|
||||||
|
? a.name.localeCompare(b.name)
|
||||||
|
: b.name.localeCompare(a.name)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortByModified = (
|
||||||
|
a: ProjectWithEntryPointMetadata,
|
||||||
|
b: ProjectWithEntryPointMetadata
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
a.entrypoint_metadata?.modifiedAt &&
|
||||||
|
b.entrypoint_metadata?.modifiedAt
|
||||||
|
) {
|
||||||
|
return !sortBy || sortBy.includes('desc')
|
||||||
|
? b.entrypoint_metadata.modifiedAt.getTime() -
|
||||||
|
a.entrypoint_metadata.modifiedAt.getTime()
|
||||||
|
: a.entrypoint_metadata.modifiedAt.getTime() -
|
||||||
|
b.entrypoint_metadata.modifiedAt.getTime()
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortBy?.includes('name')) {
|
||||||
|
return sortByName
|
||||||
|
} else {
|
||||||
|
return sortByModified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen overflow-hidden relative flex flex-col">
|
||||||
|
<AppHeader showToolbar={false} />
|
||||||
|
<div className="my-24 overflow-y-auto max-w-5xl w-full mx-auto">
|
||||||
|
<section className="flex justify-between">
|
||||||
|
<h1 className="text-3xl text-bold">Your Projects</h1>
|
||||||
|
<div className="flex">
|
||||||
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
|
onClick={() => setSearchParams(getNextSearchParams('name'))}
|
||||||
|
icon={{
|
||||||
|
icon: getSortIcon('name'),
|
||||||
|
bgClassName: !sort?.includes('name')
|
||||||
|
? 'bg-liquid-30 dark:bg-liquid-70'
|
||||||
|
: '',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Name
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
|
onClick={() => setSearchParams(getNextSearchParams('modified'))}
|
||||||
|
icon={{
|
||||||
|
icon: sort ? getSortIcon('modified') : faArrowDown,
|
||||||
|
bgClassName: !(
|
||||||
|
sort?.includes('modified') ||
|
||||||
|
!sort ||
|
||||||
|
sort === null
|
||||||
|
)
|
||||||
|
? 'bg-liquid-30 dark:bg-liquid-70'
|
||||||
|
: '',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Last Modified
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<p className="my-4 text-sm text-chalkboard-80 dark:text-chalkboard-30">
|
||||||
|
Are being saved at{' '}
|
||||||
|
<code className="text-liquid-80 dark:text-liquid-30">
|
||||||
|
{defaultDir.dir}
|
||||||
|
</code>
|
||||||
|
, which you can change in your <Link to="settings">Settings</Link>.
|
||||||
|
</p>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loading>Loading your Projects...</Loading>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{projects.length > 0 ? (
|
||||||
|
<ul className="my-8 w-full grid grid-cols-4 gap-4">
|
||||||
|
{projects.sort(getSortFunction(sort)).map((project) => (
|
||||||
|
<ProjectCard
|
||||||
|
key={project.name}
|
||||||
|
project={project}
|
||||||
|
handleRenameProject={handleRenameProject}
|
||||||
|
handleDeleteProject={handleDeleteProject}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="rounded my-8 border border-dashed border-chalkboard-30 dark:border-chalkboard-70 p-4">
|
||||||
|
No Projects found, ready to make your first one?
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
|
onClick={handleNewProject}
|
||||||
|
icon={{ icon: faPlus }}
|
||||||
|
>
|
||||||
|
New file
|
||||||
|
</ActionButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Home
|
@ -1,14 +1,14 @@
|
|||||||
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
|
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { ActionButton } from '../../components/ActionButton'
|
import { ActionButton } from '../../components/ActionButton'
|
||||||
import { useDismiss, useNextClick } from '.'
|
import { onboardingPaths, useDismiss, useNextClick } from '.'
|
||||||
import { useStore } from '../../useStore'
|
import { useStore } from '../../useStore'
|
||||||
|
|
||||||
const Units = () => {
|
export default function Units() {
|
||||||
const { isMouseDownInStream } = useStore((s) => ({
|
const { isMouseDownInStream } = useStore((s) => ({
|
||||||
isMouseDownInStream: s.isMouseDownInStream,
|
isMouseDownInStream: s.isMouseDownInStream,
|
||||||
}))
|
}))
|
||||||
const dismiss = useDismiss()
|
const dismiss = useDismiss()
|
||||||
const next = useNextClick('sketching')
|
const next = useNextClick(onboardingPaths.SKETCHING)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed grid justify-center items-end inset-0 z-50 pointer-events-none">
|
<div className="fixed grid justify-center items-end inset-0 z-50 pointer-events-none">
|
||||||
@ -26,6 +26,7 @@ const Units = () => {
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex justify-between mt-6">
|
<div className="flex justify-between mt-6">
|
||||||
<ActionButton
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
onClick={dismiss}
|
onClick={dismiss}
|
||||||
icon={{
|
icon={{
|
||||||
icon: faXmark,
|
icon: faXmark,
|
||||||
@ -37,7 +38,11 @@ const Units = () => {
|
|||||||
>
|
>
|
||||||
Dismiss
|
Dismiss
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
<ActionButton onClick={next} icon={{ icon: faArrowRight }}>
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
|
onClick={next}
|
||||||
|
icon={{ icon: faArrowRight }}
|
||||||
|
>
|
||||||
Next: Sketching
|
Next: Sketching
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
@ -45,5 +50,3 @@ const Units = () => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Units
|
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
|
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { ActionButton } from '../../components/ActionButton'
|
import { ActionButton } from '../../components/ActionButton'
|
||||||
import { useDismiss, useNextClick } from '.'
|
import { onboardingPaths, useDismiss, useNextClick } from '.'
|
||||||
|
|
||||||
const Introduction = () => {
|
export default function Introduction() {
|
||||||
const dismiss = useDismiss()
|
const dismiss = useDismiss()
|
||||||
const next = useNextClick('units')
|
const next = useNextClick(onboardingPaths.UNITS)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed grid place-content-center inset-0 bg-chalkboard-110/50 z-50">
|
<div className="fixed grid place-content-center inset-0 bg-chalkboard-110/50 z-50">
|
||||||
@ -22,6 +22,7 @@ const Introduction = () => {
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex justify-between mt-6">
|
<div className="flex justify-between mt-6">
|
||||||
<ActionButton
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
onClick={dismiss}
|
onClick={dismiss}
|
||||||
icon={{
|
icon={{
|
||||||
icon: faXmark,
|
icon: faXmark,
|
||||||
@ -33,7 +34,11 @@ const Introduction = () => {
|
|||||||
>
|
>
|
||||||
Dismiss
|
Dismiss
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
<ActionButton onClick={next} icon={{ icon: faArrowRight }}>
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
|
onClick={next}
|
||||||
|
icon={{ icon: faArrowRight }}
|
||||||
|
>
|
||||||
Get Started
|
Get Started
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
@ -41,5 +46,3 @@ const Introduction = () => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Introduction
|
|
||||||
|
@ -2,7 +2,7 @@ import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
|
|||||||
import { ActionButton } from '../../components/ActionButton'
|
import { ActionButton } from '../../components/ActionButton'
|
||||||
import { useDismiss } from '.'
|
import { useDismiss } from '.'
|
||||||
|
|
||||||
const Sketching = () => {
|
export default function Sketching() {
|
||||||
const dismiss = useDismiss()
|
const dismiss = useDismiss()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -14,6 +14,7 @@ const Sketching = () => {
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex justify-between mt-6">
|
<div className="flex justify-between mt-6">
|
||||||
<ActionButton
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
onClick={dismiss}
|
onClick={dismiss}
|
||||||
icon={{
|
icon={{
|
||||||
icon: faXmark,
|
icon: faXmark,
|
||||||
@ -25,7 +26,11 @@ const Sketching = () => {
|
|||||||
>
|
>
|
||||||
Dismiss
|
Dismiss
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
<ActionButton onClick={dismiss} icon={{ icon: faArrowRight }}>
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
|
onClick={dismiss}
|
||||||
|
icon={{ icon: faArrowRight }}
|
||||||
|
>
|
||||||
Finish
|
Finish
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
@ -33,5 +38,3 @@ const Sketching = () => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Sketching
|
|
||||||
|
@ -4,11 +4,11 @@ import { ActionButton } from '../../components/ActionButton'
|
|||||||
import { SettingsSection } from '../Settings'
|
import { SettingsSection } from '../Settings'
|
||||||
import { Toggle } from '../../components/Toggle/Toggle'
|
import { Toggle } from '../../components/Toggle/Toggle'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useDismiss, useNextClick } from '.'
|
import { onboardingPaths, useDismiss, useNextClick } from '.'
|
||||||
|
|
||||||
const Units = () => {
|
export default function Units() {
|
||||||
const dismiss = useDismiss()
|
const dismiss = useDismiss()
|
||||||
const next = useNextClick('camera')
|
const next = useNextClick(onboardingPaths.CAMERA)
|
||||||
const {
|
const {
|
||||||
defaultUnitSystem: ogDefaultUnitSystem,
|
defaultUnitSystem: ogDefaultUnitSystem,
|
||||||
setDefaultUnitSystem: saveDefaultUnitSystem,
|
setDefaultUnitSystem: saveDefaultUnitSystem,
|
||||||
@ -67,6 +67,7 @@ const Units = () => {
|
|||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
<div className="flex justify-between mt-6">
|
<div className="flex justify-between mt-6">
|
||||||
<ActionButton
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
onClick={dismiss}
|
onClick={dismiss}
|
||||||
icon={{
|
icon={{
|
||||||
icon: faXmark,
|
icon: faXmark,
|
||||||
@ -78,7 +79,11 @@ const Units = () => {
|
|||||||
>
|
>
|
||||||
Dismiss
|
Dismiss
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
<ActionButton onClick={handleNextClick} icon={{ icon: faArrowRight }}>
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
|
onClick={handleNextClick}
|
||||||
|
icon={{ icon: faArrowRight }}
|
||||||
|
>
|
||||||
Next: Camera
|
Next: Camera
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
@ -86,5 +91,3 @@ const Units = () => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Units
|
|
||||||
|
@ -8,12 +8,13 @@ import Camera from './Camera'
|
|||||||
import Sketching from './Sketching'
|
import Sketching from './Sketching'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { paths } from '../../Router'
|
import { paths } from '../../Router'
|
||||||
|
import makeUrlPathRelative from '../../lib/makeUrlPathRelative'
|
||||||
|
|
||||||
export const onboardingPaths = {
|
export const onboardingPaths = {
|
||||||
INDEX: '',
|
INDEX: '/',
|
||||||
UNITS: 'units',
|
UNITS: '/units',
|
||||||
CAMERA: 'camera',
|
CAMERA: '/camera',
|
||||||
SKETCHING: 'sketching',
|
SKETCHING: '/sketching',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const onboardingRoutes = [
|
export const onboardingRoutes = [
|
||||||
@ -22,15 +23,15 @@ export const onboardingRoutes = [
|
|||||||
element: <Introduction />,
|
element: <Introduction />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: onboardingPaths.UNITS,
|
path: makeUrlPathRelative(onboardingPaths.UNITS),
|
||||||
element: <Units />,
|
element: <Units />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: onboardingPaths.CAMERA,
|
path: makeUrlPathRelative(onboardingPaths.CAMERA),
|
||||||
element: <Camera />,
|
element: <Camera />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: onboardingPaths.SKETCHING,
|
path: makeUrlPathRelative(onboardingPaths.SKETCHING),
|
||||||
element: <Sketching />,
|
element: <Sketching />,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@ -43,7 +44,7 @@ export function useNextClick(newStatus: string) {
|
|||||||
|
|
||||||
return useCallback(() => {
|
return useCallback(() => {
|
||||||
setOnboardingStatus(newStatus)
|
setOnboardingStatus(newStatus)
|
||||||
navigate('/onboarding/' + newStatus)
|
navigate((newStatus !== onboardingPaths.UNITS ? '..' : '.') + newStatus)
|
||||||
}, [newStatus, setOnboardingStatus, navigate])
|
}, [newStatus, setOnboardingStatus, navigate])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -16,7 +16,7 @@ import { paths } from '../Router'
|
|||||||
|
|
||||||
export const Settings = () => {
|
export const Settings = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
useHotkeys('esc', () => navigate(paths.INDEX))
|
useHotkeys('esc', () => navigate('../'))
|
||||||
const {
|
const {
|
||||||
defaultDir,
|
defaultDir,
|
||||||
setDefaultDir,
|
setDefaultDir,
|
||||||
@ -46,6 +46,7 @@ export const Settings = () => {
|
|||||||
theme: s.theme,
|
theme: s.theme,
|
||||||
setTheme: s.setTheme,
|
setTheme: s.setTheme,
|
||||||
}))
|
}))
|
||||||
|
const ogDefaultDir = useRef(defaultDir)
|
||||||
const ogDefaultProjectName = useRef(defaultProjectName)
|
const ogDefaultProjectName = useRef(defaultProjectName)
|
||||||
|
|
||||||
async function handleDirectorySelection() {
|
async function handleDirectorySelection() {
|
||||||
@ -65,7 +66,7 @@ export const Settings = () => {
|
|||||||
<AppHeader showToolbar={false}>
|
<AppHeader showToolbar={false}>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
Element="link"
|
Element="link"
|
||||||
to={paths.INDEX}
|
to={'../'}
|
||||||
icon={{
|
icon={{
|
||||||
icon: faXmark,
|
icon: faXmark,
|
||||||
bgClassName: 'bg-destroy-80',
|
bgClassName: 'bg-destroy-80',
|
||||||
@ -93,7 +94,11 @@ export const Settings = () => {
|
|||||||
base: defaultDir.base,
|
base: defaultDir.base,
|
||||||
dir: e.target.value,
|
dir: e.target.value,
|
||||||
})
|
})
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
ogDefaultDir.current.dir !== defaultDir.dir &&
|
||||||
toast.success('Default directory updated')
|
toast.success('Default directory updated')
|
||||||
|
ogDefaultDir.current.dir = defaultDir.dir
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
@ -126,6 +131,7 @@ export const Settings = () => {
|
|||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
ogDefaultProjectName.current !== defaultProjectName &&
|
ogDefaultProjectName.current !== defaultProjectName &&
|
||||||
toast.success('Default project name updated')
|
toast.success('Default project name updated')
|
||||||
|
ogDefaultProjectName.current = defaultProjectName
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
@ -210,9 +216,10 @@ export const Settings = () => {
|
|||||||
description="Replay the onboarding process"
|
description="Replay the onboarding process"
|
||||||
>
|
>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOnboardingStatus('')
|
setOnboardingStatus('')
|
||||||
navigate(paths.ONBOARDING.INDEX)
|
navigate('..' + paths.ONBOARDING.INDEX)
|
||||||
}}
|
}}
|
||||||
icon={{ icon: faArrowRotateBack }}
|
icon={{ icon: faArrowRotateBack }}
|
||||||
>
|
>
|
||||||
|
@ -61,6 +61,7 @@ const SignIn = () => {
|
|||||||
</p>
|
</p>
|
||||||
{isTauri() ? (
|
{isTauri() ? (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
onClick={signInTauri}
|
onClick={signInTauri}
|
||||||
icon={{ icon: faSignInAlt }}
|
icon={{ icon: faSignInAlt }}
|
||||||
className="w-fit mt-4"
|
className="w-fit mt-4"
|
||||||
|
@ -402,10 +402,10 @@ export const useStore = create<StoreState>()(
|
|||||||
|
|
||||||
// tauri specific app settings
|
// tauri specific app settings
|
||||||
defaultDir: {
|
defaultDir: {
|
||||||
dir: '~/Documents/',
|
dir: '',
|
||||||
},
|
},
|
||||||
setDefaultDir: (dir) => set({ defaultDir: dir }),
|
setDefaultDir: (dir) => set({ defaultDir: dir }),
|
||||||
defaultProjectName: 'new-project-$n',
|
defaultProjectName: 'new-project-$nnn',
|
||||||
setDefaultProjectName: (defaultProjectName) =>
|
setDefaultProjectName: (defaultProjectName) =>
|
||||||
set({ defaultProjectName }),
|
set({ defaultProjectName }),
|
||||||
defaultUnitSystem: 'imperial',
|
defaultUnitSystem: 'imperial',
|
||||||
|
@ -1979,7 +1979,7 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e"
|
resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e"
|
||||||
integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==
|
integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==
|
||||||
|
|
||||||
"@tauri-apps/api@^1.3.0":
|
"@tauri-apps/api@1.4.0", "@tauri-apps/api@^1.3.0":
|
||||||
version "1.4.0"
|
version "1.4.0"
|
||||||
resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-1.4.0.tgz#b4013ca3d17b853f7df29fe14079ebb4d52dbffa"
|
resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-1.4.0.tgz#b4013ca3d17b853f7df29fe14079ebb4d52dbffa"
|
||||||
integrity sha512-Jd6HPoTM1PZSFIzq7FB8VmMu3qSSyo/3lSwLpoapW+lQ41CL5Dow2KryLg+gyazA/58DRWI9vu/XpEeHK4uMdw==
|
integrity sha512-Jd6HPoTM1PZSFIzq7FB8VmMu3qSSyo/3lSwLpoapW+lQ41CL5Dow2KryLg+gyazA/58DRWI9vu/XpEeHK4uMdw==
|
||||||
@ -5613,6 +5613,12 @@ tar@^6.1.11:
|
|||||||
mkdirp "^1.0.3"
|
mkdirp "^1.0.3"
|
||||||
yallist "^4.0.0"
|
yallist "^4.0.0"
|
||||||
|
|
||||||
|
"tauri-plugin-fs-extra-api@https://github.com/tauri-apps/tauri-plugin-fs-extra#v1":
|
||||||
|
version "0.0.0"
|
||||||
|
resolved "https://github.com/tauri-apps/tauri-plugin-fs-extra#1344db48a39b44fe46e9943bf7cddca2fa00caaf"
|
||||||
|
dependencies:
|
||||||
|
"@tauri-apps/api" "1.4.0"
|
||||||
|
|
||||||
test-exclude@^6.0.0:
|
test-exclude@^6.0.0:
|
||||||
version "6.0.0"
|
version "6.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e"
|
resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e"
|
||||||
|
Reference in New Issue
Block a user