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",
|
||||
"sketch-helpers": "^0.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",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^4.4.2",
|
||||
|
13
src-tauri/Cargo.lock
generated
13
src-tauri/Cargo.lock
generated
@ -81,6 +81,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-fs-extra",
|
||||
"tokio",
|
||||
"toml 0.6.0",
|
||||
]
|
||||
@ -3120,6 +3121,18 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tauri-runtime"
|
||||
version = "0.13.0"
|
||||
|
@ -19,9 +19,10 @@ anyhow = "1"
|
||||
oauth2 = "4.4.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
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"] }
|
||||
toml = "0.6.0"
|
||||
tauri-plugin-fs-extra = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
|
||||
|
||||
[features]
|
||||
# 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(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![login, read_toml, read_txt_file])
|
||||
.plugin(tauri_plugin_fs_extra::init())
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
@ -22,9 +22,7 @@
|
||||
"save": true
|
||||
},
|
||||
"fs": {
|
||||
"scope": [
|
||||
"$HOME/**/*"
|
||||
],
|
||||
"scope": ["$HOME/**/*", "$APPDATA/**/*"],
|
||||
"all": true
|
||||
},
|
||||
"http": {
|
||||
@ -37,6 +35,9 @@
|
||||
},
|
||||
"shell": {
|
||||
"open": true
|
||||
},
|
||||
"path": {
|
||||
"all": true
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { App } from './App'
|
||||
import { describe, test, vi } from 'vitest'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
|
||||
let listener: ((rect: any) => void) | undefined = undefined
|
||||
@ -12,7 +13,19 @@ let listener: ((rect: any) => void) | undefined = undefined
|
||||
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(
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
@ -20,4 +33,7 @@ test('renders learn react link', () => {
|
||||
)
|
||||
const linkElement = screen.getByText(/Variables/i)
|
||||
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 { getNormalisedCoordinates } from './lib/utils'
|
||||
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() {
|
||||
const { code: loadedCode } = useLoaderData() as IndexLoaderData
|
||||
const pathParams = useParams()
|
||||
const streamRef = useRef<HTMLDivElement>(null)
|
||||
useHotKeyListener()
|
||||
const {
|
||||
@ -151,9 +159,34 @@ export function App() {
|
||||
? '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 = (value: string, viewUpdate: ViewUpdate) => {
|
||||
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) {
|
||||
editorView?.dispatch({ effects: addLineHighlight.of([0, 0]) })
|
||||
}
|
||||
@ -413,6 +446,11 @@ export function App() {
|
||||
paneOpacity +
|
||||
(isMouseDownInStream ? ' pointer-events-none' : '')
|
||||
}
|
||||
filename={
|
||||
pathParams.id
|
||||
?.slice(pathParams.id.lastIndexOf('/') + 1)
|
||||
.replace(FILE_EXT, '') || ''
|
||||
}
|
||||
/>
|
||||
<ModalContainer />
|
||||
<Resizable
|
||||
|
@ -13,6 +13,16 @@ import Onboarding, {
|
||||
} from './routes/Onboarding'
|
||||
import SignIn from './routes/SignIn'
|
||||
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 =
|
||||
(routesObject: Record<string, string>) => (prepend: string) => {
|
||||
@ -26,16 +36,34 @@ const prependRoutes =
|
||||
|
||||
export const paths = {
|
||||
INDEX: '/',
|
||||
HOME: '/home',
|
||||
FILE: '/file',
|
||||
SETTINGS: '/settings',
|
||||
SIGN_IN: '/signin',
|
||||
ONBOARDING: prependRoutes(onboardingPaths)(
|
||||
'/onboarding/'
|
||||
'/onboarding'
|
||||
) as typeof onboardingPaths,
|
||||
}
|
||||
|
||||
export type IndexLoaderData = {
|
||||
code: string | null
|
||||
}
|
||||
|
||||
export type ProjectWithEntryPointMetadata = FileEntry & {
|
||||
entrypoint_metadata: Metadata
|
||||
}
|
||||
export type HomeLoaderData = {
|
||||
projects: ProjectWithEntryPointMetadata[]
|
||||
}
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: paths.INDEX,
|
||||
loader: () =>
|
||||
isTauri() ? redirect(paths.HOME) : redirect(paths.FILE + '/new'),
|
||||
},
|
||||
{
|
||||
path: paths.FILE + '/:id',
|
||||
element: (
|
||||
<Auth>
|
||||
<Outlet />
|
||||
@ -43,7 +71,10 @@ const router = createBrowserRouter([
|
||||
</Auth>
|
||||
),
|
||||
errorElement: <ErrorPage />,
|
||||
loader: ({ request }) => {
|
||||
loader: async ({
|
||||
request,
|
||||
params,
|
||||
}): Promise<IndexLoaderData | Response> => {
|
||||
const store = localStorage.getItem('store')
|
||||
if (store === null) {
|
||||
return redirect(paths.ONBOARDING.INDEX)
|
||||
@ -60,23 +91,72 @@ const router = createBrowserRouter([
|
||||
notEnRouteToOnboarding && hasValidOnboardingStatus
|
||||
|
||||
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: [
|
||||
{
|
||||
path: paths.SETTINGS,
|
||||
path: makeUrlPathRelative(paths.SETTINGS),
|
||||
element: <Settings />,
|
||||
},
|
||||
{
|
||||
path: paths.ONBOARDING.INDEX,
|
||||
path: makeUrlPathRelative(paths.ONBOARDING.INDEX),
|
||||
element: <Onboarding />,
|
||||
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,
|
||||
element: <SignIn />,
|
||||
|
@ -1,52 +1,92 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ActionIcon, ActionIconProps } from './ActionIcon'
|
||||
import React from 'react'
|
||||
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
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
to?: string
|
||||
Element?:
|
||||
| 'button'
|
||||
| 'link'
|
||||
| React.ComponentType<React.HTMLAttributes<HTMLButtonElement>>
|
||||
}
|
||||
|
||||
export const ActionButton = ({
|
||||
icon,
|
||||
className,
|
||||
onClick,
|
||||
to = paths.INDEX,
|
||||
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}`
|
||||
type ActionButtonAsButton = BaseActionButtonProps &
|
||||
Omit<
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
keyof BaseActionButtonProps
|
||||
> & {
|
||||
Element: 'button'
|
||||
}
|
||||
|
||||
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 (
|
||||
<button onClick={onClick} className={classNames} {...props}>
|
||||
{icon && <ActionIcon {...icon} />}
|
||||
<button className={classNames} {...rest}>
|
||||
{props.icon && <ActionIcon {...icon} />}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
} else if (Element === 'link') {
|
||||
}
|
||||
case 'link': {
|
||||
const { Element, to, icon, children, className, ...rest } = props
|
||||
return (
|
||||
<Link to={to} className={classNames} {...props}>
|
||||
<Link to={to || paths.INDEX} className={classNames} {...rest}>
|
||||
{icon && <ActionIcon {...icon} />}
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
} else {
|
||||
}
|
||||
case 'externalLink': {
|
||||
const { Element, icon, children, className, ...rest } = props
|
||||
return (
|
||||
<Element onClick={onClick} className={classNames} {...props}>
|
||||
<a className={classNames} {...rest}>
|
||||
{icon && <ActionIcon {...icon} />}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,14 +3,17 @@ import { Toolbar } from '../Toolbar'
|
||||
import { useStore } from '../useStore'
|
||||
import UserSidebarMenu from './UserSidebarMenu'
|
||||
import { paths } from '../Router'
|
||||
import { isTauri } from '../lib/isTauri'
|
||||
|
||||
interface AppHeaderProps extends React.PropsWithChildren {
|
||||
showToolbar?: boolean
|
||||
filename?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const AppHeader = ({
|
||||
showToolbar = true,
|
||||
filename = '',
|
||||
children,
|
||||
className = '',
|
||||
}: AppHeaderProps) => {
|
||||
@ -25,13 +28,18 @@ export const AppHeader = ({
|
||||
className
|
||||
}
|
||||
>
|
||||
<Link to={paths.INDEX}>
|
||||
<Link
|
||||
to={isTauri() ? paths.HOME : paths.INDEX}
|
||||
className="flex items-center gap-4"
|
||||
>
|
||||
<img
|
||||
src="/kitt-arcade-winking.svg"
|
||||
alt="KittyCAD App"
|
||||
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>
|
||||
{/* Toolbar if the context deems it */}
|
||||
{showToolbar && (
|
||||
|
@ -73,6 +73,7 @@ export const DebugPanel = ({ className, ...props }: CollapsiblePanelProps) => {
|
||||
/>
|
||||
</div>
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={() => {
|
||||
engineCommandManager?.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
|
@ -166,6 +166,7 @@ export const ExportButton = () => {
|
||||
</form>
|
||||
<div className="flex justify-between mt-6">
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={closeModal}
|
||||
icon={{
|
||||
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 { 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 = {
|
||||
id: '8675309',
|
||||
name: 'Test User',
|
||||
@ -25,9 +26,9 @@ it("Renders user's name and email if available", () => {
|
||||
userWellFormed.name || ''
|
||||
)
|
||||
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 = {
|
||||
id: '8675309',
|
||||
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'))
|
||||
|
||||
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 = {
|
||||
id: '8675309',
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
@ -5,6 +5,7 @@ import { faBars, faGear, faSignOutAlt } from '@fortawesome/free-solid-svg-icons'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useState } from 'react'
|
||||
import { paths } from '../Router'
|
||||
import makeUrlPathRelative from '../lib/makeUrlPathRelative'
|
||||
|
||||
const UserSidebarMenu = ({ user }: { user?: User }) => {
|
||||
const displayedName = getDisplayName(user)
|
||||
@ -95,13 +96,14 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
|
||||
)}
|
||||
<div className="p-4 flex flex-col gap-2">
|
||||
<ActionButton
|
||||
Element="button"
|
||||
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)
|
||||
navigate(makeUrlPathRelative(paths.SETTINGS))
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
|
@ -32,7 +32,7 @@ body.dark {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
@apply w-2 rounded-sm;
|
||||
@apply w-2 h-2 rounded-sm;
|
||||
@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 { ActionButton } from '../../components/ActionButton'
|
||||
import { useDismiss, useNextClick } from '.'
|
||||
import { onboardingPaths, useDismiss, useNextClick } from '.'
|
||||
import { useStore } from '../../useStore'
|
||||
|
||||
const Units = () => {
|
||||
export default function Units() {
|
||||
const { isMouseDownInStream } = useStore((s) => ({
|
||||
isMouseDownInStream: s.isMouseDownInStream,
|
||||
}))
|
||||
const dismiss = useDismiss()
|
||||
const next = useNextClick('sketching')
|
||||
const next = useNextClick(onboardingPaths.SKETCHING)
|
||||
|
||||
return (
|
||||
<div className="fixed grid justify-center items-end inset-0 z-50 pointer-events-none">
|
||||
@ -26,6 +26,7 @@ const Units = () => {
|
||||
</p>
|
||||
<div className="flex justify-between mt-6">
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={dismiss}
|
||||
icon={{
|
||||
icon: faXmark,
|
||||
@ -37,7 +38,11 @@ const Units = () => {
|
||||
>
|
||||
Dismiss
|
||||
</ActionButton>
|
||||
<ActionButton onClick={next} icon={{ icon: faArrowRight }}>
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={next}
|
||||
icon={{ icon: faArrowRight }}
|
||||
>
|
||||
Next: Sketching
|
||||
</ActionButton>
|
||||
</div>
|
||||
@ -45,5 +50,3 @@ const Units = () => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Units
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
|
||||
import { ActionButton } from '../../components/ActionButton'
|
||||
import { useDismiss, useNextClick } from '.'
|
||||
import { onboardingPaths, useDismiss, useNextClick } from '.'
|
||||
|
||||
const Introduction = () => {
|
||||
export default function Introduction() {
|
||||
const dismiss = useDismiss()
|
||||
const next = useNextClick('units')
|
||||
const next = useNextClick(onboardingPaths.UNITS)
|
||||
|
||||
return (
|
||||
<div className="fixed grid place-content-center inset-0 bg-chalkboard-110/50 z-50">
|
||||
@ -22,6 +22,7 @@ const Introduction = () => {
|
||||
</p>
|
||||
<div className="flex justify-between mt-6">
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={dismiss}
|
||||
icon={{
|
||||
icon: faXmark,
|
||||
@ -33,7 +34,11 @@ const Introduction = () => {
|
||||
>
|
||||
Dismiss
|
||||
</ActionButton>
|
||||
<ActionButton onClick={next} icon={{ icon: faArrowRight }}>
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={next}
|
||||
icon={{ icon: faArrowRight }}
|
||||
>
|
||||
Get Started
|
||||
</ActionButton>
|
||||
</div>
|
||||
@ -41,5 +46,3 @@ const Introduction = () => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Introduction
|
||||
|
@ -2,7 +2,7 @@ import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
|
||||
import { ActionButton } from '../../components/ActionButton'
|
||||
import { useDismiss } from '.'
|
||||
|
||||
const Sketching = () => {
|
||||
export default function Sketching() {
|
||||
const dismiss = useDismiss()
|
||||
|
||||
return (
|
||||
@ -14,6 +14,7 @@ const Sketching = () => {
|
||||
</p>
|
||||
<div className="flex justify-between mt-6">
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={dismiss}
|
||||
icon={{
|
||||
icon: faXmark,
|
||||
@ -25,7 +26,11 @@ const Sketching = () => {
|
||||
>
|
||||
Dismiss
|
||||
</ActionButton>
|
||||
<ActionButton onClick={dismiss} icon={{ icon: faArrowRight }}>
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={dismiss}
|
||||
icon={{ icon: faArrowRight }}
|
||||
>
|
||||
Finish
|
||||
</ActionButton>
|
||||
</div>
|
||||
@ -33,5 +38,3 @@ const Sketching = () => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Sketching
|
||||
|
@ -4,11 +4,11 @@ import { ActionButton } from '../../components/ActionButton'
|
||||
import { SettingsSection } from '../Settings'
|
||||
import { Toggle } from '../../components/Toggle/Toggle'
|
||||
import { useState } from 'react'
|
||||
import { useDismiss, useNextClick } from '.'
|
||||
import { onboardingPaths, useDismiss, useNextClick } from '.'
|
||||
|
||||
const Units = () => {
|
||||
export default function Units() {
|
||||
const dismiss = useDismiss()
|
||||
const next = useNextClick('camera')
|
||||
const next = useNextClick(onboardingPaths.CAMERA)
|
||||
const {
|
||||
defaultUnitSystem: ogDefaultUnitSystem,
|
||||
setDefaultUnitSystem: saveDefaultUnitSystem,
|
||||
@ -67,6 +67,7 @@ const Units = () => {
|
||||
</SettingsSection>
|
||||
<div className="flex justify-between mt-6">
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={dismiss}
|
||||
icon={{
|
||||
icon: faXmark,
|
||||
@ -78,7 +79,11 @@ const Units = () => {
|
||||
>
|
||||
Dismiss
|
||||
</ActionButton>
|
||||
<ActionButton onClick={handleNextClick} icon={{ icon: faArrowRight }}>
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={handleNextClick}
|
||||
icon={{ icon: faArrowRight }}
|
||||
>
|
||||
Next: Camera
|
||||
</ActionButton>
|
||||
</div>
|
||||
@ -86,5 +91,3 @@ const Units = () => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Units
|
||||
|
@ -8,12 +8,13 @@ import Camera from './Camera'
|
||||
import Sketching from './Sketching'
|
||||
import { useCallback } from 'react'
|
||||
import { paths } from '../../Router'
|
||||
import makeUrlPathRelative from '../../lib/makeUrlPathRelative'
|
||||
|
||||
export const onboardingPaths = {
|
||||
INDEX: '',
|
||||
UNITS: 'units',
|
||||
CAMERA: 'camera',
|
||||
SKETCHING: 'sketching',
|
||||
INDEX: '/',
|
||||
UNITS: '/units',
|
||||
CAMERA: '/camera',
|
||||
SKETCHING: '/sketching',
|
||||
}
|
||||
|
||||
export const onboardingRoutes = [
|
||||
@ -22,15 +23,15 @@ export const onboardingRoutes = [
|
||||
element: <Introduction />,
|
||||
},
|
||||
{
|
||||
path: onboardingPaths.UNITS,
|
||||
path: makeUrlPathRelative(onboardingPaths.UNITS),
|
||||
element: <Units />,
|
||||
},
|
||||
{
|
||||
path: onboardingPaths.CAMERA,
|
||||
path: makeUrlPathRelative(onboardingPaths.CAMERA),
|
||||
element: <Camera />,
|
||||
},
|
||||
{
|
||||
path: onboardingPaths.SKETCHING,
|
||||
path: makeUrlPathRelative(onboardingPaths.SKETCHING),
|
||||
element: <Sketching />,
|
||||
},
|
||||
]
|
||||
@ -43,7 +44,7 @@ export function useNextClick(newStatus: string) {
|
||||
|
||||
return useCallback(() => {
|
||||
setOnboardingStatus(newStatus)
|
||||
navigate('/onboarding/' + newStatus)
|
||||
navigate((newStatus !== onboardingPaths.UNITS ? '..' : '.') + newStatus)
|
||||
}, [newStatus, setOnboardingStatus, navigate])
|
||||
}
|
||||
|
||||
|
@ -16,7 +16,7 @@ import { paths } from '../Router'
|
||||
|
||||
export const Settings = () => {
|
||||
const navigate = useNavigate()
|
||||
useHotkeys('esc', () => navigate(paths.INDEX))
|
||||
useHotkeys('esc', () => navigate('../'))
|
||||
const {
|
||||
defaultDir,
|
||||
setDefaultDir,
|
||||
@ -46,6 +46,7 @@ export const Settings = () => {
|
||||
theme: s.theme,
|
||||
setTheme: s.setTheme,
|
||||
}))
|
||||
const ogDefaultDir = useRef(defaultDir)
|
||||
const ogDefaultProjectName = useRef(defaultProjectName)
|
||||
|
||||
async function handleDirectorySelection() {
|
||||
@ -65,7 +66,7 @@ export const Settings = () => {
|
||||
<AppHeader showToolbar={false}>
|
||||
<ActionButton
|
||||
Element="link"
|
||||
to={paths.INDEX}
|
||||
to={'../'}
|
||||
icon={{
|
||||
icon: faXmark,
|
||||
bgClassName: 'bg-destroy-80',
|
||||
@ -93,7 +94,11 @@ export const Settings = () => {
|
||||
base: defaultDir.base,
|
||||
dir: e.target.value,
|
||||
})
|
||||
}}
|
||||
onBlur={() => {
|
||||
ogDefaultDir.current.dir !== defaultDir.dir &&
|
||||
toast.success('Default directory updated')
|
||||
ogDefaultDir.current.dir = defaultDir.dir
|
||||
}}
|
||||
/>
|
||||
<ActionButton
|
||||
@ -126,6 +131,7 @@ export const Settings = () => {
|
||||
onBlur={() => {
|
||||
ogDefaultProjectName.current !== defaultProjectName &&
|
||||
toast.success('Default project name updated')
|
||||
ogDefaultProjectName.current = defaultProjectName
|
||||
}}
|
||||
/>
|
||||
</SettingsSection>
|
||||
@ -210,9 +216,10 @@ export const Settings = () => {
|
||||
description="Replay the onboarding process"
|
||||
>
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={() => {
|
||||
setOnboardingStatus('')
|
||||
navigate(paths.ONBOARDING.INDEX)
|
||||
navigate('..' + paths.ONBOARDING.INDEX)
|
||||
}}
|
||||
icon={{ icon: faArrowRotateBack }}
|
||||
>
|
||||
|
@ -61,6 +61,7 @@ const SignIn = () => {
|
||||
</p>
|
||||
{isTauri() ? (
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={signInTauri}
|
||||
icon={{ icon: faSignInAlt }}
|
||||
className="w-fit mt-4"
|
||||
|
@ -402,10 +402,10 @@ export const useStore = create<StoreState>()(
|
||||
|
||||
// tauri specific app settings
|
||||
defaultDir: {
|
||||
dir: '~/Documents/',
|
||||
dir: '',
|
||||
},
|
||||
setDefaultDir: (dir) => set({ defaultDir: dir }),
|
||||
defaultProjectName: 'new-project-$n',
|
||||
defaultProjectName: 'new-project-$nnn',
|
||||
setDefaultProjectName: (defaultProjectName) =>
|
||||
set({ defaultProjectName }),
|
||||
defaultUnitSystem: 'imperial',
|
||||
|
@ -1979,7 +1979,7 @@
|
||||
resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e"
|
||||
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"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-1.4.0.tgz#b4013ca3d17b853f7df29fe14079ebb4d52dbffa"
|
||||
integrity sha512-Jd6HPoTM1PZSFIzq7FB8VmMu3qSSyo/3lSwLpoapW+lQ41CL5Dow2KryLg+gyazA/58DRWI9vu/XpEeHK4uMdw==
|
||||
@ -5613,6 +5613,12 @@ tar@^6.1.11:
|
||||
mkdirp "^1.0.3"
|
||||
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:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e"
|
||||
|
Reference in New Issue
Block a user