diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-1-Google-Chrome-linux.png index db836bb56..778847819 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-1-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Millimeter-scale-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Millimeter-scale-1-Google-Chrome-linux.png index 2f5aa7db9..2f57e42fb 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Millimeter-scale-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Millimeter-scale-1-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable-YZ-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable-YZ-1-Google-Chrome-linux.png index 3a9b18145..e0622e051 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable-YZ-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable-YZ-1-Google-Chrome-linux.png differ diff --git a/src/components/CustomIcon.tsx b/src/components/CustomIcon.tsx index 0639b4437..f86bc562d 100644 --- a/src/components/CustomIcon.tsx +++ b/src/components/CustomIcon.tsx @@ -395,6 +395,14 @@ const CustomIconMap = { /> ), + trash: ( + + + + ), vertical: ( , - f: Project - ) => Promise - handleDeleteProject: (f: Project) => Promise -}) { - useHotkeys('esc', () => setIsEditing(false)) - const [isEditing, setIsEditing] = useState(false) - const [isConfirmingDelete, setIsConfirmingDelete] = useState(false) - const [numberOfFiles, setNumberOfFiles] = useState(1) - const [numberOfFolders, setNumberOfFolders] = useState(0) - - let inputRef = useRef(null) - - function handleSave(e: FormEvent) { - e.preventDefault() - void handleRenameProject(e, project).then(() => setIsEditing(false)) - } - - function getDisplayedTime(dateStr: string) { - const date = new Date(dateStr) - const startOfToday = new Date() - startOfToday.setHours(0, 0, 0, 0) - return date.getTime() < startOfToday.getTime() - ? date.toLocaleDateString() - : date.toLocaleTimeString() - } - - useEffect(() => { - async function getNumberOfFiles() { - setNumberOfFiles(project.kcl_file_count) - setNumberOfFolders(project.directory_count) - } - void getNumberOfFiles() - }, [project.kcl_file_count, project.directory_count]) - - useEffect(() => { - if (inputRef.current) { - inputRef.current.focus() - inputRef.current.select() - } - }, [inputRef.current]) - - return ( -
  • - {isEditing ? ( -
    - -
    - - - Rename project - - - setIsEditing(false)} - > - - Cancel - - -
    -
    - ) : ( - <> - -
    {project.name?.replace(FILE_EXT, '')}
    - - {numberOfFiles} file{numberOfFiles === 1 ? '' : 's'}{' '} - {numberOfFolders > 0 && - `/ ${numberOfFolders} folder${ - numberOfFolders === 1 ? '' : 's' - }`} - - - Edited{' '} - {project.metadata && project.metadata?.modified - ? getDisplayedTime(project.metadata.modified) - : 'never'} - - -
    - { - e.stopPropagation() - e.nativeEvent.stopPropagation() - setIsEditing(true) - }} - className="!p-0" - > - - Rename project - - - { - e.stopPropagation() - e.nativeEvent.stopPropagation() - setIsConfirmingDelete(true) - }} - > - - Delete project - - -
    - setIsConfirmingDelete(false)} - className="relative z-50" - > -
    - - - Delete File - - - This will permanently delete "{project.name || 'this file'}". - - -

    - Are you sure you want to delete "{project.name || 'this file'} - "? This action cannot be undone. -

    - -
    - { - await handleDeleteProject(project) - setIsConfirmingDelete(false) - }} - iconStart={{ - icon: faTrashAlt, - bgClassName: 'bg-destroy-80', - className: 'p-1', - size: 'sm', - iconClassName: '!text-destroy-70 dark:!text-destroy-40', - }} - className="hover:border-destroy-40 dark:hover:border-destroy-40" - > - Delete - - setIsConfirmingDelete(false)} - > - Cancel - -
    -
    -
    -
    - - )} -
  • - ) -} - -export default ProjectCard diff --git a/src/components/ProjectCard/DeleteProjectDialog.tsx b/src/components/ProjectCard/DeleteProjectDialog.tsx new file mode 100644 index 000000000..a62f85cf6 --- /dev/null +++ b/src/components/ProjectCard/DeleteProjectDialog.tsx @@ -0,0 +1,53 @@ +import { Dialog } from '@headlessui/react' +import { ActionButton } from 'components/ActionButton' + +interface DeleteProjectDialogProps { + projectName: string + onConfirm: () => void + onDismiss: () => void +} + +export function DeleteProjectDialog({ + projectName, + onConfirm, + onDismiss, +}: DeleteProjectDialogProps) { + return ( + +
    + + + Delete File + + + This will permanently delete "{projectName || 'this file'} + ". + + +

    + Are you sure you want to delete "{projectName || 'this file'} + "? This action cannot be undone. +

    + +
    + + Delete + + + Cancel + +
    +
    +
    +
    + ) +} diff --git a/src/components/ProjectCard/ProjectCard.tsx b/src/components/ProjectCard/ProjectCard.tsx new file mode 100644 index 000000000..8307d1596 --- /dev/null +++ b/src/components/ProjectCard/ProjectCard.tsx @@ -0,0 +1,176 @@ +import { FormEvent, useEffect, useRef, useState } from 'react' +import { paths } from 'lib/paths' +import { Link } from 'react-router-dom' +import { ActionButton } from '../ActionButton' +import { FILE_EXT } from 'lib/constants' +import { useHotkeys } from 'react-hotkeys-hook' +import Tooltip from '../Tooltip' +import { DeleteProjectDialog } from './DeleteProjectDialog' +import { ProjectCardRenameForm } from './ProjectCardRenameForm' +import { Project } from 'wasm-lib/kcl/bindings/Project' + +function ProjectCard({ + project, + handleRenameProject, + handleDeleteProject, + ...props +}: { + project: Project + handleRenameProject: ( + e: FormEvent, + f: Project + ) => Promise + handleDeleteProject: (f: Project) => Promise +}) { + useHotkeys('esc', () => setIsEditing(false)) + const [isEditing, setIsEditing] = useState(false) + const [isConfirmingDelete, setIsConfirmingDelete] = useState(false) + const [numberOfFiles, setNumberOfFiles] = useState(1) + const [numberOfFolders, setNumberOfFolders] = useState(0) + // const [imageUrl, setImageUrl] = useState('') + + let inputRef = useRef(null) + + function handleSave(e: FormEvent) { + e.preventDefault() + void handleRenameProject(e, project).then(() => setIsEditing(false)) + } + + function getDisplayedTime(dateStr: string) { + const date = new Date(dateStr) + const startOfToday = new Date() + startOfToday.setHours(0, 0, 0, 0) + return date.getTime() < startOfToday.getTime() + ? date.toLocaleDateString() + : date.toLocaleTimeString() + } + + useEffect(() => { + async function getNumberOfFiles() { + setNumberOfFiles(project.kcl_file_count) + setNumberOfFolders(project.directory_count) + } + + // async function setupImageUrl() { + // const projectImagePath = await join(project.path, PROJECT_IMAGE_NAME) + // if (await exists(projectImagePath)) { + // const imageData = await readFile(projectImagePath) + // const blob = new Blob([imageData], { type: 'image/jpg' }) + // const imageUrl = URL.createObjectURL(blob) + // setImageUrl(imageUrl) + // } + // } + + void getNumberOfFiles() + // void setupImageUrl() + }, [project.kcl_file_count, project.directory_count]) + + useEffect(() => { + if (inputRef.current && isEditing) { + inputRef.current.focus() + inputRef.current.select() + } + }, [isEditing, inputRef.current]) + + return ( +
  • + + {/*
    + {imageUrl && ( + + )} +
    */} +
    + {isEditing ? ( + e.stopPropagation()} + project={project} + onDismiss={() => setIsEditing(false)} + ref={inputRef} + /> + ) : ( +

    + {project.name?.replace(FILE_EXT, '')} +

    + )} + + {numberOfFiles} file{numberOfFiles === 1 ? '' : 's'}{' '} + {numberOfFolders > 0 && + `/ ${numberOfFolders} folder${numberOfFolders === 1 ? '' : 's'}`} + + + Edited{' '} + {project.metadata && project.metadata?.modified + ? getDisplayedTime(project.metadata.modified) + : 'never'} + +
    + + {!isEditing && ( +
    + { + e.stopPropagation() + e.nativeEvent.stopPropagation() + setIsEditing(true) + }} + className="!p-0" + > + + Rename project + + + { + e.stopPropagation() + e.nativeEvent.stopPropagation() + setIsConfirmingDelete(true) + }} + > + + Delete project + + +
    + )} + {isConfirmingDelete && ( + { + await handleDeleteProject(project) + setIsConfirmingDelete(false) + }} + onDismiss={() => setIsConfirmingDelete(false)} + /> + )} +
  • + ) +} + +export default ProjectCard diff --git a/src/components/ProjectCard/ProjectCardRenameForm.tsx b/src/components/ProjectCard/ProjectCardRenameForm.tsx new file mode 100644 index 000000000..837b05db1 --- /dev/null +++ b/src/components/ProjectCard/ProjectCardRenameForm.tsx @@ -0,0 +1,67 @@ +import { ActionButton } from 'components/ActionButton' +import Tooltip from 'components/Tooltip' +import { HTMLProps, forwardRef } from 'react' +import { Project } from 'wasm-lib/kcl/bindings/Project' + +interface ProjectCardRenameFormProps extends HTMLProps { + project: Project + onDismiss: () => void +} + +export const ProjectCardRenameForm = forwardRef( + ( + { project, onDismiss, ...props }: ProjectCardRenameFormProps, + ref: React.Ref + ) => { + return ( +
    + e.preventDefault()} + name="newProjectName" + required + autoCorrect="off" + autoCapitalize="off" + defaultValue={project.name} + ref={ref} + onKeyDown={(e) => { + if (e.key === 'Escape') { + onDismiss() + } + }} + /> +
    + + + Rename project + + + + + Cancel + + +
    +
    + ) + } +) diff --git a/src/lib/constants.ts b/src/lib/constants.ts index e233fb408..14ca4aafa 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -24,6 +24,8 @@ export const PROJECT_FOLDER = 'zoo-modeling-app-projects' export const FILE_EXT = '.kcl' /** Default file to open when a project is opened */ export const PROJECT_ENTRYPOINT = `main${FILE_EXT}` as const +/** Thumbnail file name */ +export const PROJECT_IMAGE_NAME = `main.jpg` as const /** The localStorage key for last-opened projects */ export const FILE_PERSIST_KEY = `${PROJECT_FOLDER}-last-opened` as const /** The default name given to new kcl files in a project */ diff --git a/src/lib/sorting.ts b/src/lib/sorting.ts index 2e89cc5ee..64c52b264 100644 --- a/src/lib/sorting.ts +++ b/src/lib/sorting.ts @@ -1,19 +1,18 @@ -import { - faArrowDown, - faArrowUp, - faCircle, -} from '@fortawesome/free-solid-svg-icons' +import { CustomIconName } from 'components/CustomIcon' import { Project } from 'wasm-lib/kcl/bindings/Project' const DESC = ':desc' -export function getSortIcon(currentSort: string, newSort: string) { +export function getSortIcon( + currentSort: string, + newSort: string +): CustomIconName { if (currentSort === newSort) { - return faArrowUp + return 'arrowUp' } else if (currentSort === newSort + DESC) { - return faArrowDown + return 'arrowDown' } - return faCircle + return 'horizontalDash' } export function getNextSearchParams(currentSort: string, newSort: string) { diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx index e828fbc09..b636f754d 100644 --- a/src/routes/Home.tsx +++ b/src/routes/Home.tsx @@ -4,16 +4,15 @@ import { getNextProjectIndex, interpolateProjectNameWithIndex, doesProjectNameNeedInterpolated, -} from '../lib/tauriFS' -import { ActionButton } from '../components/ActionButton' -import { faArrowDown, faPlus } from '@fortawesome/free-solid-svg-icons' +} from 'lib/tauriFS' +import { ActionButton } from 'components/ActionButton' import { toast } from 'react-hot-toast' -import { AppHeader } from '../components/AppHeader' -import ProjectCard from '../components/ProjectCard' +import { AppHeader } from 'components/AppHeader' +import ProjectCard from 'components/ProjectCard/ProjectCard' import { useLoaderData, useNavigate, useSearchParams } from 'react-router-dom' import { Link } from 'react-router-dom' import { type HomeLoaderData } from 'lib/types' -import Loading from '../components/Loading' +import Loading from 'components/Loading' import { useMachine } from '@xstate/react' import { homeMachine } from '../machines/homeMachine' import { ContextFrom, EventFrom } from 'xstate' @@ -187,9 +186,11 @@ const Home = () => { new FormData(e.target as HTMLFormElement) ) - send('Rename project', { - data: { oldName: project.name, newName: newProjectName }, - }) + if (newProjectName !== project.name) { + send('Rename project', { + data: { oldName: project.name, newName: newProjectName }, + }) + } } async function handleDeleteProject(project: Project) { @@ -199,71 +200,93 @@ const Home = () => { return (
    -
    -
    -

    Your Projects

    -
    - Sort by - setSearchParams(getNextSearchParams(sort, 'name'))} - iconStart={{ - icon: getSortIcon(sort, 'name'), - className: 'p-1.5', - iconClassName: !sort.includes('name') - ? '!text-chalkboard-40' - : '', - size: 'sm', - }} - > - Name - - - setSearchParams(getNextSearchParams(sort, 'modified')) - } - iconStart={{ - icon: sort ? getSortIcon(sort, 'modified') : faArrowDown, - className: 'p-1.5', - iconClassName: !isSortByModified ? '!text-chalkboard-40' : '', - size: 'sm', - }} - > - Last Modified - +
    +
    +
    +
    +

    Your Projects

    + send('Create project')} + className="group !bg-primary !text-chalkboard-10 !border-primary hover:shadow-inner hover:hue-rotate-15" + iconStart={{ + icon: 'plus', + bgClassName: '!bg-transparent rounded-sm', + iconClassName: + '!text-chalkboard-10 transition-transform group-active:rotate-90', + }} + data-testid="home-new-file" + > + New project + +
    +
    + Sort by + + setSearchParams(getNextSearchParams(sort, 'name')) + } + iconStart={{ + icon: getSortIcon(sort, 'name'), + bgClassName: 'bg-transparent', + iconClassName: !sort.includes('name') + ? '!text-chalkboard-90 dark:!text-chalkboard-30' + : '', + }} + > + Name + + + setSearchParams(getNextSearchParams(sort, 'modified')) + } + iconStart={{ + icon: sort ? getSortIcon(sort, 'modified') : 'arrowDown', + bgClassName: 'bg-transparent', + iconClassName: !isSortByModified + ? '!text-chalkboard-90 dark:!text-chalkboard-30' + : '', + }} + > + Last Modified + +
    -
    -

    Loaded from{' '} - + {settings.app.projectDirectory.current} - - .{' '} - - Edit in settings .

    +
    +
    {state.matches('Reading projects') ? ( Loading your Projects... ) : ( <> {projects.length > 0 ? ( -
      +
        {projects.sort(getSortFunction(sort)).map((project) => ( { No Projects found, ready to make your first one?

        )} - send('Create project')} - iconStart={{ icon: faPlus, iconClassName: 'p-1 w-4' }} - data-testid="home-new-file" - > - New project - )}