Rework home layout to have a sidebar (#6423)

* chore: saving off skeleton

* fix: saving skeleton

* chore: skeleton for loading projects from project directory path

* chore: cleaning up useless state transition to be an on event direct to action state

* fix: new structure for web vs desktop vs react machine provider code

* chore: saving off skeleton

* fix: skeleton logic for react? going to move it from a string to obj.string

* fix: trying to prevent error element unmount on global react components. This is bricking JS state

* fix: we are so back

* chore: implemented navigating to specfic KCL file

* chore: implementing renaming project

* chore: deleting project

* fix: auto fixes

* fix: old debug/testing file oops

* chore: generic create new file

* chore: skeleton for web create file provide

* chore: basic machine vitest... need to figure out how to get window.electron implemented in vitest?

* chore: save off progress before deleting other project implementation, a few missing features still

* chore: trying a different init skeleton? most likely will migrate

* chore: first attempt of purging projects context provider

* chore: enabling toast for some machine state

* chore: enabling more toast success and error

* chore: writing read write state to the system io based on the project path

* fix: tsc fixes

* fix: use file system watcher, navigate to project after creation via the requestProjectName

* chore: open project command, hooks vs snapshot context helpers

* chore: implemented open and create project for e2e testing. They are hard coded in poor spot for now.

* fix: codespell fixes

* chore: implementing more project commands

* chore: PR improvements for root.tsx

* chore: leaving comment about new Router.tsx layout

* fix: removing debugging code

* fix: rewriting component for readability

* fix: improving web initialization

* chore: implementing import file from url which is not actually that?

* fix: clearing search params on import file from url

* fix: fixed two e2e tests, forgot needsReview when making new command

* fix: fixing some import from url business logic to pass e2e tests

* chore: script for diffing circular deps +/-

* fix: formatting

* fix: massive fix for circular depsga!

* fix: trying to fix some errors and auto fmt

* fix: updating deps

* fix: removing debugging code

* fix: big clean up

* fix: more deletion

* fix: tsc cleanup

* fix: TSC TSC TSC TSC!

* fix: typo fix

* fix: clear query params on web only, desktop not required

* fix: removing unused code

* fmt

* Bring back `trap` removed in merge

* Use explicit types instead of `any`s on arg configs

* Add project commands directly to command palette

* fix: deleting debugging code, from PR review

* fix: this got added back(?)

* fix: using referred type

* fix: more PR clean up

* fix: big block comment for xstate architecture decision

* fix: more pr comment fixes

* fix: saving off logic, need a big cleanup because I hacked it together to get a POC

* fix: extra business?

* fix: merge conflict just added them back why dude

* fix: more PR comments

* fix: big ciruclar deps fix, commandBarActor in appActor

* chore: writing e2e test, still need to fix 3 bugs

* chore: adding more scenarios

* fix: formatting

* fix: fixing tsc errors

* chore: deleting the old text to cad and using the new application level one, almost there

* fix: prompt to edit works

* fix: large push to get 1 text to cad command... the usage is a little buggy with delete and navigate within /file

* fix: settings for highlight edges now works

* chore: adding another e2e test

* fix: cleaning up e2e tests and writing more of them

* fix: tsc type

* chore: more e2e improvements, unique project name  in text to cad

* chore: e2e tests should be good to go

* fix: gotcha comment

* fix: enabled web t2c, codespell fixes

* fix: fixing merge conflcits??

* fix: t2c is back

* Rework home layout to have a sidebar

fmt I think

* Add two links to the bottom of the sidebar

Mostly to visually anchor it

* Tweak some style things

* update test util whose locator needs to change

* tsc and fmt

* Stupid heading change broke the dang E2E tests

* Make that heading locator a part of the home page fixture

* pierremtb/new-snaps-for-frank (#6516)

Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com>

---------

Co-authored-by: Kevin Nadro <kevin@zoo.dev>
Co-authored-by: Kevin Nadro <nadr0@users.noreply.github.com>
Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com>
This commit is contained in:
Frank Noirot
2025-04-26 11:55:01 -04:00
committed by GitHub
parent 9d893d8f0b
commit 0426967bf7
33 changed files with 318 additions and 188 deletions

View File

@ -247,7 +247,7 @@ export class ElectronZoo {
}
if (!this.firstUrl) {
await this.page.getByText('Your Projects').count()
await this.page.getByRole('heading', { name: 'Projects' }).count()
this.firstUrl = this.page.url()
}

View File

@ -92,6 +92,13 @@ export class HomePageFixture {
}
}
expectIsCurrentPage = async () => {
await expect(this.page).toHaveURL(/.*home/)
await expect(
this.page.getByRole('heading', { name: 'Projects' })
).toBeVisible()
}
projectsLoaded = async () => {
await expect(this.projectSection).not.toHaveText('Loading your Projects...')
}

View File

@ -510,9 +510,7 @@ test('Restarting onboarding on desktop takes one attempt', async ({
})
await test.step('Navigate into project', async () => {
await expect(
page.getByRole('heading', { name: 'Your Projects' })
).toBeVisible()
await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible()
await expect(projectCard).toBeVisible()
await projectCard.click()
await u.waitForPageLoad()

View File

@ -723,7 +723,7 @@ test(
test(
'pressing "delete" on home screen should do nothing',
{ tag: '@electron' },
async ({ context, page }, testInfo) => {
async ({ context, page, homePage }, testInfo) => {
await context.folderSetupFn(async (dir) => {
await fsp.mkdir(`${dir}/router-template-slate`, { recursive: true })
await fsp.copyFile(
@ -737,7 +737,7 @@ test(
await expect(page.getByText('router-template-slate')).toBeVisible()
await expect(page.getByText('Loading your Projects...')).not.toBeVisible()
await expect(page.getByText('Your Projects')).toBeVisible()
await homePage.expectIsCurrentPage()
await page.keyboard.press('Delete')
await page.waitForTimeout(200)
@ -745,7 +745,7 @@ test(
// expect to still be on the home page
await expect(page.getByText('router-template-slate')).toBeVisible()
await expect(page.getByText('Your Projects')).toBeVisible()
await homePage.expectIsCurrentPage()
}
)

View File

@ -579,7 +579,7 @@ extrude002 = extrude(profile002, length = 150)
// Locators
const projectsHeading = page.getByRole('heading', {
name: 'Your projects',
name: 'Projects',
})
const projectLink = page.getByRole('link', { name: 'bracket' })
const networkHealthIndicator = page.getByTestId('network-toggle')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View File

@ -1014,7 +1014,7 @@ export async function createProject({
async function goToHomePageFromModeling(page: Page) {
await page.getByTestId('app-logo').click()
await expect(page.getByText('Your Projects')).toBeVisible()
await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible()
}
export function executorInputPath(fileName: string): string {

View File

@ -786,7 +786,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
test(
'Home Page -> Text To CAD -> Existing Project -> Stay in home page -> Reject -> should delete single file',
{ tag: '@electron' },
async ({ context, page }, testInfo) => {
async ({ homePage, page }, testInfo) => {
const projectName = 'my-project-name'
const prompt = '2x2x2 cube'
await mockPageTextToCAD(page)
@ -794,7 +794,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
// Create and navigate to the project then come home
await createProject({ name: projectName, page, returnHome: true })
await expect(page.getByText('Your Projects')).toBeVisible()
await homePage.expectIsCurrentPage()
await expect(page.getByText('1 file')).toBeVisible()
@ -829,7 +829,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
test(
'Home Page -> Text To CAD -> Existing Project -> Stay in home page -> Accept -> should navigate to file',
{ tag: '@electron' },
async ({ context, page }, testInfo) => {
async ({ homePage, page }, testInfo) => {
const u = await getUtils(page)
const projectName = 'my-project-name'
const prompt = '2x2x2 cube'
@ -838,7 +838,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
// Create and navigate to the project then come home
await createProject({ name: projectName, page, returnHome: true })
await expect(page.getByText('Your Projects')).toBeVisible()
await homePage.expectIsCurrentPage()
// open commands
await page.getByTestId('command-bar-open-button').click()
@ -880,7 +880,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
test(
'Home Page -> Text To CAD -> New Project -> Navigate to the project -> Reject -> should go to home page',
{ tag: '@electron' },
async ({ context, page }, testInfo) => {
async ({ homePage, page }, testInfo) => {
const projectName = 'my-project-name'
const prompt = '2x2x2 cube'
await mockPageTextToCAD(page)
@ -919,16 +919,14 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
await page.getByRole('button', { name: 'Reject' }).click()
// Make sure we went back home
await expect(
page.getByText('No Projects found, ready to make your first one?')
).toBeVisible()
await homePage.expectIsCurrentPage()
}
)
test(
'Home Page -> Text To CAD -> New Project -> Navigate to the project -> Accept -> should stay in same file',
{ tag: '@electron' },
async ({ context, page }, testInfo) => {
async ({ homePage, page }, testInfo) => {
const projectName = 'my-project-name'
const prompt = '2x2x2 cube'
await mockPageTextToCAD(page)
@ -971,7 +969,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
test(
'Home Page -> Text To CAD -> Existing Project -> Navigate to the project -> Reject -> should load main.kcl',
{ tag: '@electron' },
async ({ context, page }, testInfo) => {
async ({ homePage, page }, testInfo) => {
const u = await getUtils(page)
const projectName = 'my-project-name'
const prompt = '2x2x2 cube'
@ -980,7 +978,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
// Create and navigate to the project then come home
await createProject({ name: projectName, page, returnHome: true })
await expect(page.getByText('Your Projects')).toBeVisible()
await homePage.expectIsCurrentPage()
// open commands
await page.getByTestId('command-bar-open-button').click()
@ -1026,7 +1024,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
test(
'Home Page -> Text To CAD -> Existing Project -> Navigate to the project -> Accept -> should load 2x2x2-cube.kcl',
{ tag: '@electron' },
async ({ context, page }, testInfo) => {
async ({ homePage, page }, testInfo) => {
const u = await getUtils(page)
const projectName = 'my-project-name'
const prompt = '2x2x2 cube'
@ -1035,7 +1033,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
// Create and navigate to the project then come home
await createProject({ name: projectName, page, returnHome: true })
await expect(page.getByText('Your Projects')).toBeVisible()
await homePage.expectIsCurrentPage()
// open commands
await page.getByTestId('command-bar-open-button').click()
@ -1083,7 +1081,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
test(
'Home Page -> Text To CAD -> New Project -> Navigate to different project -> Reject -> should stay in project',
{ tag: '@electron' },
async ({ context, page, homePage }, testInfo) => {
async ({ homePage, page }, testInfo) => {
const u = await getUtils(page)
const projectName = 'my-project-name'
const unrelatedProjectName = 'unrelated-project'
@ -1097,7 +1095,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
returnHome: true,
})
await expect(page.getByText('Your Projects')).toBeVisible()
await homePage.expectIsCurrentPage()
// open commands
await page.getByTestId('command-bar-open-button').click()
@ -1160,7 +1158,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
test(
'Home Page -> Text To CAD -> New Project -> Navigate to different project -> Accept -> should go to new project',
{ tag: '@electron' },
async ({ context, page, homePage }, testInfo) => {
async ({ page, homePage }, testInfo) => {
const u = await getUtils(page)
const projectName = 'my-project-name'
const unrelatedProjectName = 'unrelated-project'
@ -1174,7 +1172,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
returnHome: true,
})
await expect(page.getByText('Your Projects')).toBeVisible()
await homePage.expectIsCurrentPage()
// open commands
await page.getByTestId('command-bar-open-button').click()
@ -1233,7 +1231,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
test(
'Home Page -> Text To CAD -> Existing Project -> Navigate to different project -> Reject -> should stay in same project',
{ tag: '@electron' },
async ({ context, page, homePage }, testInfo) => {
async ({ page, homePage }, testInfo) => {
const u = await getUtils(page)
const projectName = 'my-project-name'
const unrelatedProjectName = 'unrelated-project'
@ -1246,10 +1244,10 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
page,
returnHome: true,
})
await expect(page.getByText('Your Projects')).toBeVisible()
await homePage.expectIsCurrentPage()
await createProject({ name: projectName, page, returnHome: true })
await expect(page.getByText('Your Projects')).toBeVisible()
await homePage.expectIsCurrentPage()
// open commands
await page.getByTestId('command-bar-open-button').click()
@ -1308,7 +1306,7 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
test(
'Home Page -> Text To CAD -> Existing Project -> Navigate to different project -> Accept -> should navigate to new project',
{ tag: '@electron' },
async ({ context, page, homePage }, testInfo) => {
async ({ page, homePage }, testInfo) => {
const u = await getUtils(page)
const projectName = 'my-project-name'
const unrelatedProjectName = 'unrelated-project'
@ -1321,10 +1319,10 @@ test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
page,
returnHome: true,
})
await expect(page.getByText('Your Projects')).toBeVisible()
await homePage.expectIsCurrentPage()
await createProject({ name: projectName, page, returnHome: true })
await expect(page.getByText('Your Projects')).toBeVisible()
await homePage.expectIsCurrentPage()
// open commands
await page.getByTestId('command-bar-open-button').click()

View File

@ -44,7 +44,7 @@ export type ActionButtonProps =
| ActionButtonAsElement
export const ActionButton = forwardRef((props: ActionButtonProps, ref) => {
const classNames = `action-button leading-[0.7] p-0 m-0 group mono text-xs leading-none flex items-center gap-2 rounded-sm border-solid border border-chalkboard-30 hover:border-chalkboard-40 enabled:dark:border-chalkboard-70 dark:hover:border-chalkboard-60 dark:bg-chalkboard-90/50 text-chalkboard-100 dark:text-chalkboard-10 ${
const classNames = `action-button leading-[0.7] p-0 m-0 group text-xs leading-none flex items-center gap-2 rounded-sm border-solid border border-chalkboard-30 hover:border-chalkboard-40 enabled:dark:border-chalkboard-70 dark:hover:border-chalkboard-60 dark:bg-chalkboard-90/50 text-chalkboard-100 dark:text-chalkboard-10 ${
props.iconStart
? props.iconEnd
? 'px-0' // No padding if both icons are present

View File

@ -84,7 +84,7 @@ function ProjectCard({
return (
<li
{...props}
className="group relative flex flex-col rounded-sm border border-primary/40 dark:border-chalkboard-80 hover:!border-primary"
className="group relative flex flex-col rounded-sm border border-chalkboard-50 dark:border-chalkboard-80 hover:!border-primary"
>
<Link
data-testid="project-link"
@ -93,7 +93,7 @@ function ProjectCard({
? `${PATHS.FILE}/${encodeURIComponent(project.default_file)}`
: ''
}
className={`flex flex-col flex-1 !no-underline !text-chalkboard-110 dark:!text-chalkboard-10 min-h-[5em] divide-y divide-primary/40 dark:divide-chalkboard-80 ${
className={`flex flex-col flex-1 !no-underline !text-chalkboard-110 dark:!text-chalkboard-10 min-h-[5em] divide-y divide-chalkboard-50 dark:divide-chalkboard-80 ${
project.readWriteAccess
? 'group-hover:!divide-primary group-hover:!hue-rotate-0'
: 'cursor-not-allowed'

View File

@ -286,6 +286,11 @@ code {
@apply bg-chalkboard-20 dark:bg-chalkboard-90;
@apply border border-t-0 border-b-2 border-chalkboard-30 dark:border-chalkboard-80;
}
.home-layout {
@apply grid lg:grid-cols-3 xl:grid-cols-4 gap-4 lg:gap-x-16;
grid-template-rows: auto 1fr;
}
}
@layer utilities {

View File

@ -1,4 +1,4 @@
import type { FormEvent } from 'react'
import type { FormEvent, HTMLProps } from 'react'
import { useEffect, useRef } from 'react'
import { toast } from 'react-hot-toast'
import { useHotkeys } from 'react-hotkeys-hook'
@ -38,11 +38,16 @@ import {
SystemIOMachineStates,
} from '@src/machines/systemIO/utils'
import type { WebContentSendPayload } from '@src/menu/channels'
import { openExternalBrowserIfDesktop } from '@src/lib/openWindow'
type ReadWriteProjectState = {
value: boolean
error: unknown
}
// This route only opens in the desktop context for now,
// as defined in Router.tsx, so we can use the desktop APIs and types.
const Home = () => {
const state = useSystemIOState()
const readWriteProjectDir = useCanReadWriteProjectDirectory()
// Only create the native file menus on desktop
@ -117,9 +122,9 @@ const Home = () => {
} else if (data.menuLabel === 'File.Preferences.Keybindings') {
navigate(PATHS.HOME + PATHS.SETTINGS_KEYBINDINGS)
} else if (data.menuLabel === 'File.Preferences.User default units') {
navigate(PATHS.HOME + PATHS.SETTINGS_USER + '#defaultUnit')
navigate(`${PATHS.HOME}${PATHS.SETTINGS_USER}#defaultUnit`)
} else if (data.menuLabel === 'Edit.Change project directory') {
navigate(PATHS.HOME + PATHS.SETTINGS_USER + '#projectDirectory')
navigate(`${PATHS.HOME}${PATHS.SETTINGS_USER}#projectDirectory`)
} else if (data.menuLabel === 'File.Sign out') {
authActor.send({ type: 'Log out' })
} else if (
@ -136,7 +141,7 @@ const Home = () => {
},
})
} else if (data.menuLabel === 'File.Preferences.Theme color') {
navigate(PATHS.HOME + PATHS.SETTINGS_USER + '#themeColor')
navigate(`${PATHS.HOME}${PATHS.SETTINGS_USER}#themeColor`)
}
}
useMenuListener(cb)
@ -162,60 +167,24 @@ const Home = () => {
const [searchParams, setSearchParams] = useSearchParams()
const { searchResults, query, setQuery } = useProjectSearch(projects)
const sort = searchParams.get('sort_by') ?? 'modified:desc'
const isSortByModified = sort?.includes('modified') || !sort || sort === null
async function handleRenameProject(
e: FormEvent<HTMLFormElement>,
project: Project
) {
const { newProjectName } = Object.fromEntries(
new FormData(e.target as HTMLFormElement)
)
if (typeof newProjectName === 'string' && newProjectName.startsWith('.')) {
toast.error('Project names cannot start with a dot (.)')
return
}
if (newProjectName !== project.name) {
systemIOActor.send({
type: SystemIOMachineEvents.renameProject,
data: {
requestedProjectName: String(newProjectName),
projectName: project.name,
},
})
}
}
async function handleDeleteProject(project: Project) {
systemIOActor.send({
type: SystemIOMachineEvents.deleteProject,
data: { requestedProjectName: project.name },
})
}
/** Type narrowing function of unknown error to a string */
function errorMessage(error: unknown): string {
if (error != undefined && error instanceof Error) {
return error.message
} else if (error && typeof error === 'object') {
return JSON.stringify(error)
} else if (typeof error === 'string') {
return error
} else {
return 'Unknown error'
}
}
const sidebarButtonClasses =
'flex items-center p-2 gap-2 leading-tight border-transparent dark:border-transparent enabled:dark:border-transparent enabled:hover:border-primary/50 enabled:dark:hover:border-inherit active:border-primary dark:bg-transparent hover:bg-transparent'
return (
<div className="relative flex flex-col h-screen overflow-hidden" ref={ref}>
<AppHeader showToolbar={false} />
<div className="w-full flex flex-col overflow-hidden max-w-5xl px-4 mx-auto mt-24 lg:px-2">
<section>
<div className="flex justify-between items-center select-none">
<div className="flex gap-8 items-center">
<h1 className="text-3xl font-bold">Your Projects</h1>
<div className="overflow-hidden home-layout max-w-4xl xl:max-w-7xl mb-12 px-4 mx-auto mt-24 lg:px-0">
<HomeHeader
setQuery={setQuery}
sort={sort}
setSearchParams={setSearchParams}
settings={settings}
readWriteProjectDir={readWriteProjectDir}
className="col-start-2 -col-end-1"
/>
<aside className="row-start-2 -row-end-1 flex flex-col justify-between">
<ul className="flex flex-col">
<li className="contents">
<ActionButton
Element="button"
onClick={() =>
@ -227,17 +196,115 @@ const Home = () => {
},
})
}
className="group !bg-primary !text-chalkboard-10 !border-primary hover:shadow-inner hover:hue-rotate-15"
className={sidebarButtonClasses}
iconStart={{
icon: 'plus',
bgClassName: '!bg-transparent rounded-sm',
iconClassName:
'!text-chalkboard-10 transition-transform group-active:rotate-90',
}}
data-testid="home-new-file"
>
Create project
</ActionButton>
</li>
<li className="contents">
<ActionButton
Element="button"
onClick={() =>
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'application',
name: 'Text-to-CAD',
argDefaultValues: {
method: 'newProject',
newProjectName:
settings.projects.defaultProjectName.current,
},
},
})
}
className={sidebarButtonClasses}
iconStart={{
icon: 'sparkles',
bgClassName: '!bg-transparent rounded-sm',
}}
data-testid="home-text-to-cad"
>
Generate with Text-to-CAD
</ActionButton>
</li>
</ul>
<ul className="flex flex-col">
<li className="contents">
<ActionButton
Element="externalLink"
to="https://zoo.dev/docs"
onClick={openExternalBrowserIfDesktop(
'https://zoo.dev/account'
)}
className={sidebarButtonClasses}
iconStart={{
icon: 'person',
bgClassName: '!bg-transparent rounded-sm',
}}
data-testid="home-account"
>
View your account
</ActionButton>
</li>
<li className="contents">
<ActionButton
Element="externalLink"
to="https://zoo.dev/blog"
onClick={openExternalBrowserIfDesktop('https://zoo.dev/blog')}
className={sidebarButtonClasses}
iconStart={{
icon: 'sketch',
bgClassName: '!bg-transparent rounded-sm',
}}
data-testid="home-blog"
>
Read the Zoo blog
</ActionButton>
</li>
</ul>
</aside>
<ProjectGrid
searchResults={searchResults}
projects={projects}
query={query}
sort={sort}
className="flex-1 col-start-2 -col-end-1 overflow-y-auto pr-2 pb-24"
/>
<LowerRightControls />
</div>
</div>
)
}
interface HomeHeaderProps extends HTMLProps<HTMLDivElement> {
setQuery: (query: string) => void
sort: string
setSearchParams: (params: Record<string, string>) => void
settings: ReturnType<typeof useSettings>
readWriteProjectDir: ReadWriteProjectState
}
function HomeHeader({
setQuery,
sort,
setSearchParams,
settings,
readWriteProjectDir,
...rest
}: HomeHeaderProps) {
const isSortByModified = sort?.includes('modified') || !sort || sort === null
return (
<section {...rest}>
<div className="flex justify-between items-center select-none">
<div className="flex gap-8 items-center">
<h1 className="text-3xl font-bold">Projects</h1>
</div>
<div className="flex gap-2 items-center">
<ProjectSearchBar setQuery={setQuery} />
@ -245,15 +312,12 @@ const Home = () => {
<ActionButton
Element="button"
data-testid="home-sort-by-name"
className={
'text-xs border-primary/10 ' +
(!sort.includes('name')
className={`text-xs border-primary/10 ${
!sort.includes('name')
? 'text-chalkboard-80 dark:text-chalkboard-40'
: '')
}
onClick={() =>
setSearchParams(getNextSearchParams(sort, 'name'))
}
: ''
}`}
onClick={() => setSearchParams(getNextSearchParams(sort, 'name'))}
iconStart={{
icon: getSortIcon(sort, 'name'),
bgClassName: 'bg-transparent',
@ -267,12 +331,11 @@ const Home = () => {
<ActionButton
Element="button"
data-testid="home-sort-by-modified"
className={
'text-xs border-primary/10 ' +
(!isSortByModified
className={`text-xs border-primary/10 ${
!isSortByModified
? 'text-chalkboard-80 dark:text-chalkboard-40'
: '')
}
: ''
}`}
onClick={() =>
setSearchParams(getNextSearchParams(sort, 'modified'))
}
@ -302,7 +365,7 @@ const Home = () => {
{!readWriteProjectDir.value && (
<section>
<div className="flex items-center select-none">
<div className="flex gap-8 items-center justify-between grow bg-destroy-80 text-white py-1 px-4 my-2 rounded-sm grow">
<div className="flex gap-8 items-center justify-between grow bg-destroy-80 text-white py-1 px-4 my-2 rounded-sm">
<p className="">{errorMessage(readWriteProjectDir.error)}</p>
<Link
data-testid="project-directory-settings-link"
@ -316,16 +379,33 @@ const Home = () => {
</section>
)}
</section>
<section
data-testid="home-section"
className="flex-1 overflow-y-auto pr-2 pb-24"
>
{state?.matches(SystemIOMachineStates.readingFolders) ? (
<Loading className="h-screen">Loading your Projects...</Loading>
)
}
interface ProjectGridProps extends HTMLProps<HTMLDivElement> {
searchResults: Project[]
projects: Project[]
query: string
sort: string
}
function ProjectGrid({
searchResults,
projects,
query,
sort,
...rest
}: ProjectGridProps) {
const state = useSystemIOState()
return (
<section data-testid="home-section" {...rest}>
{state.matches(SystemIOMachineStates.readingFolders) ? (
<Loading>Loading your Projects...</Loading>
) : (
<>
{searchResults.length > 0 ? (
<ul className="grid w-full grid-cols-4 gap-4">
<ul className="grid w-full md:grid-cols-2 xl:grid-cols-4 gap-4">
{searchResults.sort(getSortFunction(sort)).map((project) => (
<ProjectCard
key={project.name}
@ -346,10 +426,52 @@ const Home = () => {
</>
)}
</section>
<LowerRightControls />
</div>
</div>
)
}
/** Type narrowing function of unknown error to a string */
function errorMessage(error: unknown): string {
if (error !== undefined && error instanceof Error) {
return error.message
}
if (error && typeof error === 'object') {
return JSON.stringify(error)
}
if (typeof error === 'string') {
return error
}
return 'Unknown error'
}
async function handleRenameProject(
e: FormEvent<HTMLFormElement>,
project: Project
) {
const { newProjectName } = Object.fromEntries(
new FormData(e.target as HTMLFormElement)
)
if (typeof newProjectName === 'string' && newProjectName.startsWith('.')) {
toast.error('Project names cannot start with a dot (.)')
return
}
if (newProjectName !== project.name) {
systemIOActor.send({
type: SystemIOMachineEvents.renameProject,
data: {
requestedProjectName: String(newProjectName),
projectName: project.name,
},
})
}
}
async function handleDeleteProject(project: Project) {
systemIOActor.send({
type: SystemIOMachineEvents.deleteProject,
data: { requestedProjectName: project.name },
})
}
export default Home