Home page touch-ups (#2135)
* Save part images when navigating home * Load part images in project cards if available * Polish home page * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * Merge branch 'main' into franknoirot/project-images * Mostly restored link + form functionality * Working cards with images * Comment out project image stuff * Little style tweaks * Remove unused imports * More minor styling tweaks * Merge branch 'main' into franknoirot/project-images * Was using the wrong imported `Project` type * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * Revert any docs changes * Revert wasm-lib divergences * Move ProjectCard into its component folder * Remove unused hook useSaveVideoFrame * Remove "hideOnLevel" config from theme setting --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
		
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB  | 
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB  | 
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 40 KiB  | 
@ -395,6 +395,14 @@ const CustomIconMap = {
 | 
			
		||||
      />
 | 
			
		||||
    </svg>
 | 
			
		||||
  ),
 | 
			
		||||
  trash: (
 | 
			
		||||
    <svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
 | 
			
		||||
      <path
 | 
			
		||||
        d="M8.5 6H5V8H6M8.5 6V4H11.5V6M8.5 6H11.5M11.5 6H15V8H14M6 8V15.5H8M6 8H14M14 8V15.5H12M8 15.5V10M8 15.5H10M12 15.5V10M12 15.5H10M10 15.5V12"
 | 
			
		||||
        stroke="currentColor"
 | 
			
		||||
      />
 | 
			
		||||
    </svg>
 | 
			
		||||
  ),
 | 
			
		||||
  vertical: (
 | 
			
		||||
    <svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
 | 
			
		||||
      <path
 | 
			
		||||
 | 
			
		||||
@ -1,235 +0,0 @@
 | 
			
		||||
import { FormEvent, useEffect, useRef, useState } from 'react'
 | 
			
		||||
import { paths } from 'lib/paths'
 | 
			
		||||
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/constants'
 | 
			
		||||
import { Dialog } from '@headlessui/react'
 | 
			
		||||
import { useHotkeys } from 'react-hotkeys-hook'
 | 
			
		||||
import Tooltip from './Tooltip'
 | 
			
		||||
import { Project } from 'wasm-lib/kcl/bindings/Project'
 | 
			
		||||
 | 
			
		||||
function ProjectCard({
 | 
			
		||||
  project,
 | 
			
		||||
  handleRenameProject,
 | 
			
		||||
  handleDeleteProject,
 | 
			
		||||
  ...props
 | 
			
		||||
}: {
 | 
			
		||||
  project: Project
 | 
			
		||||
  handleRenameProject: (
 | 
			
		||||
    e: FormEvent<HTMLFormElement>,
 | 
			
		||||
    f: Project
 | 
			
		||||
  ) => Promise<void>
 | 
			
		||||
  handleDeleteProject: (f: Project) => Promise<void>
 | 
			
		||||
}) {
 | 
			
		||||
  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<HTMLInputElement>(null)
 | 
			
		||||
 | 
			
		||||
  function handleSave(e: FormEvent<HTMLFormElement>) {
 | 
			
		||||
    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 (
 | 
			
		||||
    <li
 | 
			
		||||
      {...props}
 | 
			
		||||
      className="group relative min-h-[5em] p-1 rounded-sm border border-chalkboard-20 dark:border-chalkboard-80 hover:!border-primary"
 | 
			
		||||
    >
 | 
			
		||||
      {isEditing ? (
 | 
			
		||||
        <form onSubmit={handleSave} className="flex items-center gap-2">
 | 
			
		||||
          <input
 | 
			
		||||
            className="min-w-0 p-1 dark:bg-chalkboard-80 dark:border-chalkboard-40 focus:outline-none"
 | 
			
		||||
            type="text"
 | 
			
		||||
            id="newProjectName"
 | 
			
		||||
            name="newProjectName"
 | 
			
		||||
            autoCorrect="off"
 | 
			
		||||
            autoCapitalize="off"
 | 
			
		||||
            defaultValue={project.name}
 | 
			
		||||
            ref={inputRef}
 | 
			
		||||
          />
 | 
			
		||||
          <div className="flex items-center gap-1">
 | 
			
		||||
            <ActionButton
 | 
			
		||||
              Element="button"
 | 
			
		||||
              type="submit"
 | 
			
		||||
              iconStart={{
 | 
			
		||||
                icon: faCheck,
 | 
			
		||||
                size: 'sm',
 | 
			
		||||
                className: 'p-1',
 | 
			
		||||
                bgClassName: '!bg-transparent',
 | 
			
		||||
              }}
 | 
			
		||||
              className="!p-0"
 | 
			
		||||
            >
 | 
			
		||||
              <Tooltip position="left" delay={1000}>
 | 
			
		||||
                Rename project
 | 
			
		||||
              </Tooltip>
 | 
			
		||||
            </ActionButton>
 | 
			
		||||
            <ActionButton
 | 
			
		||||
              Element="button"
 | 
			
		||||
              iconStart={{
 | 
			
		||||
                icon: faX,
 | 
			
		||||
                size: 'sm',
 | 
			
		||||
                iconClassName: 'dark:!text-chalkboard-20',
 | 
			
		||||
                bgClassName: '!bg-transparent',
 | 
			
		||||
                className: 'p-1',
 | 
			
		||||
              }}
 | 
			
		||||
              className="!p-0"
 | 
			
		||||
              onClick={() => setIsEditing(false)}
 | 
			
		||||
            >
 | 
			
		||||
              <Tooltip position="left" delay={1000}>
 | 
			
		||||
                Cancel
 | 
			
		||||
              </Tooltip>
 | 
			
		||||
            </ActionButton>
 | 
			
		||||
          </div>
 | 
			
		||||
        </form>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <>
 | 
			
		||||
          <Link
 | 
			
		||||
            className="relative z-0 flex flex-col h-full gap-2 p-1 !no-underline !text-chalkboard-110 dark:!text-chalkboard-10"
 | 
			
		||||
            to={`${paths.FILE}/${encodeURIComponent(project.default_file)}`}
 | 
			
		||||
            data-testid="project-link"
 | 
			
		||||
          >
 | 
			
		||||
            <div className="flex-1">{project.name?.replace(FILE_EXT, '')}</div>
 | 
			
		||||
            <span className="text-xs text-chalkboard-60">
 | 
			
		||||
              {numberOfFiles} file{numberOfFiles === 1 ? '' : 's'}{' '}
 | 
			
		||||
              {numberOfFolders > 0 &&
 | 
			
		||||
                `/ ${numberOfFolders} folder${
 | 
			
		||||
                  numberOfFolders === 1 ? '' : 's'
 | 
			
		||||
                }`}
 | 
			
		||||
            </span>
 | 
			
		||||
            <span className="text-xs text-chalkboard-60">
 | 
			
		||||
              Edited{' '}
 | 
			
		||||
              {project.metadata && project.metadata?.modified
 | 
			
		||||
                ? getDisplayedTime(project.metadata.modified)
 | 
			
		||||
                : 'never'}
 | 
			
		||||
            </span>
 | 
			
		||||
          </Link>
 | 
			
		||||
          <div className="absolute z-10 flex items-center gap-1 opacity-0 bottom-2 right-2 group-hover:opacity-100 group-focus-within:opacity-100">
 | 
			
		||||
            <ActionButton
 | 
			
		||||
              Element="button"
 | 
			
		||||
              iconStart={{
 | 
			
		||||
                icon: faPenAlt,
 | 
			
		||||
                className: 'p-1',
 | 
			
		||||
                iconClassName: 'dark:!text-chalkboard-20',
 | 
			
		||||
                bgClassName: '!bg-transparent',
 | 
			
		||||
                size: 'xs',
 | 
			
		||||
              }}
 | 
			
		||||
              onClick={(e) => {
 | 
			
		||||
                e.stopPropagation()
 | 
			
		||||
                e.nativeEvent.stopPropagation()
 | 
			
		||||
                setIsEditing(true)
 | 
			
		||||
              }}
 | 
			
		||||
              className="!p-0"
 | 
			
		||||
            >
 | 
			
		||||
              <Tooltip position="left" delay={1000}>
 | 
			
		||||
                Rename project
 | 
			
		||||
              </Tooltip>
 | 
			
		||||
            </ActionButton>
 | 
			
		||||
            <ActionButton
 | 
			
		||||
              Element="button"
 | 
			
		||||
              iconStart={{
 | 
			
		||||
                icon: faTrashAlt,
 | 
			
		||||
                className: 'p-1',
 | 
			
		||||
                size: 'xs',
 | 
			
		||||
                bgClassName: '!bg-transparent',
 | 
			
		||||
                iconClassName: '!text-destroy-70',
 | 
			
		||||
              }}
 | 
			
		||||
              className="!p-0 hover:border-destroy-40 dark:hover:border-destroy-40"
 | 
			
		||||
              onClick={(e) => {
 | 
			
		||||
                e.stopPropagation()
 | 
			
		||||
                e.nativeEvent.stopPropagation()
 | 
			
		||||
                setIsConfirmingDelete(true)
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              <Tooltip position="left" delay={1000}>
 | 
			
		||||
                Delete project
 | 
			
		||||
              </Tooltip>
 | 
			
		||||
            </ActionButton>
 | 
			
		||||
          </div>
 | 
			
		||||
          <Dialog
 | 
			
		||||
            open={isConfirmingDelete}
 | 
			
		||||
            onClose={() => setIsConfirmingDelete(false)}
 | 
			
		||||
            className="relative z-50"
 | 
			
		||||
          >
 | 
			
		||||
            <div className="fixed inset-0 grid bg-chalkboard-110/80 place-content-center">
 | 
			
		||||
              <Dialog.Panel className="max-w-2xl p-4 border rounded bg-chalkboard-10 dark:bg-chalkboard-100 border-destroy-80">
 | 
			
		||||
                <Dialog.Title as="h2" className="mb-4 text-2xl font-bold">
 | 
			
		||||
                  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)
 | 
			
		||||
                    }}
 | 
			
		||||
                    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
 | 
			
		||||
                  </ActionButton>
 | 
			
		||||
                  <ActionButton
 | 
			
		||||
                    Element="button"
 | 
			
		||||
                    onClick={() => setIsConfirmingDelete(false)}
 | 
			
		||||
                  >
 | 
			
		||||
                    Cancel
 | 
			
		||||
                  </ActionButton>
 | 
			
		||||
                </div>
 | 
			
		||||
              </Dialog.Panel>
 | 
			
		||||
            </div>
 | 
			
		||||
          </Dialog>
 | 
			
		||||
        </>
 | 
			
		||||
      )}
 | 
			
		||||
    </li>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default ProjectCard
 | 
			
		||||
							
								
								
									
										53
									
								
								src/components/ProjectCard/DeleteProjectDialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								src/components/ProjectCard/DeleteProjectDialog.tsx
									
									
									
									
									
										Normal file
									
								
							@ -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 (
 | 
			
		||||
    <Dialog open={true} onClose={onDismiss} className="relative z-50">
 | 
			
		||||
      <div className="fixed inset-0 grid bg-chalkboard-110/80 place-content-center">
 | 
			
		||||
        <Dialog.Panel className="max-w-2xl p-4 border rounded bg-chalkboard-10 dark:bg-chalkboard-100 border-destroy-80">
 | 
			
		||||
          <Dialog.Title as="h2" className="mb-4 text-2xl font-bold">
 | 
			
		||||
            Delete File
 | 
			
		||||
          </Dialog.Title>
 | 
			
		||||
          <Dialog.Description>
 | 
			
		||||
            This will permanently delete "{projectName || 'this file'}
 | 
			
		||||
            ".
 | 
			
		||||
          </Dialog.Description>
 | 
			
		||||
 | 
			
		||||
          <p className="my-4">
 | 
			
		||||
            Are you sure you want to delete "{projectName || 'this file'}
 | 
			
		||||
            "? This action cannot be undone.
 | 
			
		||||
          </p>
 | 
			
		||||
 | 
			
		||||
          <div className="flex justify-between">
 | 
			
		||||
            <ActionButton
 | 
			
		||||
              Element="button"
 | 
			
		||||
              onClick={onConfirm}
 | 
			
		||||
              iconStart={{
 | 
			
		||||
                icon: 'trash',
 | 
			
		||||
                bgClassName: 'bg-destroy-10 dark:bg-destroy-80',
 | 
			
		||||
                iconClassName: '!text-destroy-80 dark:!text-destroy-20',
 | 
			
		||||
              }}
 | 
			
		||||
              className="hover:border-destroy-40 dark:hover:border-destroy-40 hover:bg-destroy-10/20 dark:hover:bg-destroy-80/20"
 | 
			
		||||
            >
 | 
			
		||||
              Delete
 | 
			
		||||
            </ActionButton>
 | 
			
		||||
            <ActionButton Element="button" onClick={onDismiss}>
 | 
			
		||||
              Cancel
 | 
			
		||||
            </ActionButton>
 | 
			
		||||
          </div>
 | 
			
		||||
        </Dialog.Panel>
 | 
			
		||||
      </div>
 | 
			
		||||
    </Dialog>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										176
									
								
								src/components/ProjectCard/ProjectCard.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										176
									
								
								src/components/ProjectCard/ProjectCard.tsx
									
									
									
									
									
										Normal file
									
								
							@ -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<HTMLFormElement>,
 | 
			
		||||
    f: Project
 | 
			
		||||
  ) => Promise<void>
 | 
			
		||||
  handleDeleteProject: (f: Project) => Promise<void>
 | 
			
		||||
}) {
 | 
			
		||||
  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<HTMLInputElement>(null)
 | 
			
		||||
 | 
			
		||||
  function handleSave(e: FormEvent<HTMLFormElement>) {
 | 
			
		||||
    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 (
 | 
			
		||||
    <li
 | 
			
		||||
      {...props}
 | 
			
		||||
      className="group relative flex flex-col rounded-sm border border-primary/40 dark:border-chalkboard-80 hover:!border-primary"
 | 
			
		||||
    >
 | 
			
		||||
      <Link
 | 
			
		||||
        data-testid="project-link"
 | 
			
		||||
        to={`${paths.FILE}/${encodeURIComponent(project.default_file)}`}
 | 
			
		||||
        className="flex flex-col flex-1 !no-underline !text-chalkboard-110 dark:!text-chalkboard-10 group-hover:!hue-rotate-0 min-h-[5em] divide-y divide-primary/40 dark:divide-chalkboard-80 group-hover:!divide-primary"
 | 
			
		||||
      >
 | 
			
		||||
        {/* <div className="h-36 relative overflow-hidden bg-gradient-to-b from-transparent to-primary/10 rounded-t-sm">
 | 
			
		||||
          {imageUrl && (
 | 
			
		||||
            <img
 | 
			
		||||
              src={imageUrl}
 | 
			
		||||
              alt=""
 | 
			
		||||
              className="h-full w-full transition-transform group-hover:scale-105 object-cover"
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
        </div> */}
 | 
			
		||||
        <div className="pb-2 flex flex-col flex-grow flex-auto gap-2 rounded-b-sm">
 | 
			
		||||
          {isEditing ? (
 | 
			
		||||
            <ProjectCardRenameForm
 | 
			
		||||
              onSubmit={handleSave}
 | 
			
		||||
              className="flex items-center gap-2 p-2"
 | 
			
		||||
              onClick={(e) => e.stopPropagation()}
 | 
			
		||||
              project={project}
 | 
			
		||||
              onDismiss={() => setIsEditing(false)}
 | 
			
		||||
              ref={inputRef}
 | 
			
		||||
            />
 | 
			
		||||
          ) : (
 | 
			
		||||
            <h3 className="font-sans relative z-0 p-2">
 | 
			
		||||
              {project.name?.replace(FILE_EXT, '')}
 | 
			
		||||
            </h3>
 | 
			
		||||
          )}
 | 
			
		||||
          <span className="px-2 text-chalkboard-60 text-xs">
 | 
			
		||||
            {numberOfFiles} file{numberOfFiles === 1 ? '' : 's'}{' '}
 | 
			
		||||
            {numberOfFolders > 0 &&
 | 
			
		||||
              `/ ${numberOfFolders} folder${numberOfFolders === 1 ? '' : 's'}`}
 | 
			
		||||
          </span>
 | 
			
		||||
          <span className="px-2 text-chalkboard-60 text-xs">
 | 
			
		||||
            Edited{' '}
 | 
			
		||||
            {project.metadata && project.metadata?.modified
 | 
			
		||||
              ? getDisplayedTime(project.metadata.modified)
 | 
			
		||||
              : 'never'}
 | 
			
		||||
          </span>
 | 
			
		||||
        </div>
 | 
			
		||||
      </Link>
 | 
			
		||||
      {!isEditing && (
 | 
			
		||||
        <div className="absolute z-10 flex items-center gap-1 opacity-0 bottom-2 right-2 group-hover:opacity-100 group-focus-within:opacity-100">
 | 
			
		||||
          <ActionButton
 | 
			
		||||
            Element="button"
 | 
			
		||||
            iconStart={{
 | 
			
		||||
              icon: 'sketch',
 | 
			
		||||
              iconClassName: 'dark:!text-chalkboard-20',
 | 
			
		||||
              bgClassName: '!bg-transparent',
 | 
			
		||||
            }}
 | 
			
		||||
            onClick={(e) => {
 | 
			
		||||
              e.stopPropagation()
 | 
			
		||||
              e.nativeEvent.stopPropagation()
 | 
			
		||||
              setIsEditing(true)
 | 
			
		||||
            }}
 | 
			
		||||
            className="!p-0"
 | 
			
		||||
          >
 | 
			
		||||
            <Tooltip position="top-right" delay={1000}>
 | 
			
		||||
              Rename project
 | 
			
		||||
            </Tooltip>
 | 
			
		||||
          </ActionButton>
 | 
			
		||||
          <ActionButton
 | 
			
		||||
            Element="button"
 | 
			
		||||
            iconStart={{
 | 
			
		||||
              icon: 'trash',
 | 
			
		||||
              iconClassName: 'dark:!text-chalkboard-30',
 | 
			
		||||
              bgClassName: '!bg-transparent',
 | 
			
		||||
            }}
 | 
			
		||||
            className="!p-0"
 | 
			
		||||
            onClick={(e) => {
 | 
			
		||||
              e.stopPropagation()
 | 
			
		||||
              e.nativeEvent.stopPropagation()
 | 
			
		||||
              setIsConfirmingDelete(true)
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Tooltip position="top-right" delay={1000}>
 | 
			
		||||
              Delete project
 | 
			
		||||
            </Tooltip>
 | 
			
		||||
          </ActionButton>
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
      {isConfirmingDelete && (
 | 
			
		||||
        <DeleteProjectDialog
 | 
			
		||||
          projectName={project.name}
 | 
			
		||||
          onConfirm={async () => {
 | 
			
		||||
            await handleDeleteProject(project)
 | 
			
		||||
            setIsConfirmingDelete(false)
 | 
			
		||||
          }}
 | 
			
		||||
          onDismiss={() => setIsConfirmingDelete(false)}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </li>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default ProjectCard
 | 
			
		||||
							
								
								
									
										67
									
								
								src/components/ProjectCard/ProjectCardRenameForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								src/components/ProjectCard/ProjectCardRenameForm.tsx
									
									
									
									
									
										Normal file
									
								
							@ -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<HTMLFormElement> {
 | 
			
		||||
  project: Project
 | 
			
		||||
  onDismiss: () => void
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const ProjectCardRenameForm = forwardRef(
 | 
			
		||||
  (
 | 
			
		||||
    { project, onDismiss, ...props }: ProjectCardRenameFormProps,
 | 
			
		||||
    ref: React.Ref<HTMLInputElement>
 | 
			
		||||
  ) => {
 | 
			
		||||
    return (
 | 
			
		||||
      <form {...props}>
 | 
			
		||||
        <input
 | 
			
		||||
          className="min-w-0 dark:bg-chalkboard-80 dark:border-chalkboard-40 focus:outline-none"
 | 
			
		||||
          type="text"
 | 
			
		||||
          id="newProjectName"
 | 
			
		||||
          onClickCapture={(e) => e.preventDefault()}
 | 
			
		||||
          name="newProjectName"
 | 
			
		||||
          required
 | 
			
		||||
          autoCorrect="off"
 | 
			
		||||
          autoCapitalize="off"
 | 
			
		||||
          defaultValue={project.name}
 | 
			
		||||
          ref={ref}
 | 
			
		||||
          onKeyDown={(e) => {
 | 
			
		||||
            if (e.key === 'Escape') {
 | 
			
		||||
              onDismiss()
 | 
			
		||||
            }
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
        <div className="flex items-center gap-1">
 | 
			
		||||
          <ActionButton
 | 
			
		||||
            Element="button"
 | 
			
		||||
            type="submit"
 | 
			
		||||
            iconStart={{
 | 
			
		||||
              icon: 'checkmark',
 | 
			
		||||
              bgClassName: '!bg-transparent',
 | 
			
		||||
            }}
 | 
			
		||||
            className="!p-0"
 | 
			
		||||
          >
 | 
			
		||||
            <Tooltip position="left" delay={1000}>
 | 
			
		||||
              Rename project
 | 
			
		||||
            </Tooltip>
 | 
			
		||||
          </ActionButton>
 | 
			
		||||
          <ActionButton
 | 
			
		||||
            Element="button"
 | 
			
		||||
            iconStart={{
 | 
			
		||||
              icon: 'close',
 | 
			
		||||
              iconClassName: 'dark:!text-chalkboard-20',
 | 
			
		||||
              bgClassName: '!bg-transparent',
 | 
			
		||||
            }}
 | 
			
		||||
            className="!p-0"
 | 
			
		||||
            onClick={onDismiss}
 | 
			
		||||
          >
 | 
			
		||||
            <Tooltip position="left" delay={1000}>
 | 
			
		||||
              Cancel
 | 
			
		||||
            </Tooltip>
 | 
			
		||||
          </ActionButton>
 | 
			
		||||
        </div>
 | 
			
		||||
      </form>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
)
 | 
			
		||||
@ -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 */
 | 
			
		||||
 | 
			
		||||
@ -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) {
 | 
			
		||||
 | 
			
		||||
@ -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 (
 | 
			
		||||
    <div className="relative flex flex-col h-screen overflow-hidden">
 | 
			
		||||
      <AppHeader showToolbar={false} />
 | 
			
		||||
      <div className="w-full max-w-5xl px-4 mx-auto my-24 overflow-y-auto lg:px-0">
 | 
			
		||||
        <section className="flex justify-between">
 | 
			
		||||
          <h1 className="text-3xl font-bold">Your Projects</h1>
 | 
			
		||||
          <div className="flex gap-2 items-center">
 | 
			
		||||
            <small>Sort by</small>
 | 
			
		||||
            <ActionButton
 | 
			
		||||
              Element="button"
 | 
			
		||||
              className={
 | 
			
		||||
                'text-sm ' +
 | 
			
		||||
                (!sort.includes('name')
 | 
			
		||||
                  ? 'text-chalkboard-80 dark:text-chalkboard-40'
 | 
			
		||||
                  : '')
 | 
			
		||||
              }
 | 
			
		||||
              onClick={() => setSearchParams(getNextSearchParams(sort, 'name'))}
 | 
			
		||||
              iconStart={{
 | 
			
		||||
                icon: getSortIcon(sort, 'name'),
 | 
			
		||||
                className: 'p-1.5',
 | 
			
		||||
                iconClassName: !sort.includes('name')
 | 
			
		||||
                  ? '!text-chalkboard-40'
 | 
			
		||||
                  : '',
 | 
			
		||||
                size: 'sm',
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              Name
 | 
			
		||||
            </ActionButton>
 | 
			
		||||
            <ActionButton
 | 
			
		||||
              Element="button"
 | 
			
		||||
              className={
 | 
			
		||||
                'text-sm ' +
 | 
			
		||||
                (!isSortByModified
 | 
			
		||||
                  ? 'text-chalkboard-80 dark:text-chalkboard-40'
 | 
			
		||||
                  : '')
 | 
			
		||||
              }
 | 
			
		||||
              onClick={() =>
 | 
			
		||||
                setSearchParams(getNextSearchParams(sort, 'modified'))
 | 
			
		||||
              }
 | 
			
		||||
              iconStart={{
 | 
			
		||||
                icon: sort ? getSortIcon(sort, 'modified') : faArrowDown,
 | 
			
		||||
                className: 'p-1.5',
 | 
			
		||||
                iconClassName: !isSortByModified ? '!text-chalkboard-40' : '',
 | 
			
		||||
                size: 'sm',
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              Last Modified
 | 
			
		||||
            </ActionButton>
 | 
			
		||||
      <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-baseline select-none">
 | 
			
		||||
            <div className="flex gap-8 items-baseline">
 | 
			
		||||
              <h1 className="text-3xl font-bold">Your Projects</h1>
 | 
			
		||||
              <ActionButton
 | 
			
		||||
                Element="button"
 | 
			
		||||
                onClick={() => 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
 | 
			
		||||
              </ActionButton>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div className="flex gap-2 items-center">
 | 
			
		||||
              <small>Sort by</small>
 | 
			
		||||
              <ActionButton
 | 
			
		||||
                Element="button"
 | 
			
		||||
                className={
 | 
			
		||||
                  'text-xs border-primary/10 ' +
 | 
			
		||||
                  (!sort.includes('name')
 | 
			
		||||
                    ? 'text-chalkboard-80 dark:text-chalkboard-40'
 | 
			
		||||
                    : '')
 | 
			
		||||
                }
 | 
			
		||||
                onClick={() =>
 | 
			
		||||
                  setSearchParams(getNextSearchParams(sort, 'name'))
 | 
			
		||||
                }
 | 
			
		||||
                iconStart={{
 | 
			
		||||
                  icon: getSortIcon(sort, 'name'),
 | 
			
		||||
                  bgClassName: 'bg-transparent',
 | 
			
		||||
                  iconClassName: !sort.includes('name')
 | 
			
		||||
                    ? '!text-chalkboard-90 dark:!text-chalkboard-30'
 | 
			
		||||
                    : '',
 | 
			
		||||
                }}
 | 
			
		||||
              >
 | 
			
		||||
                Name
 | 
			
		||||
              </ActionButton>
 | 
			
		||||
              <ActionButton
 | 
			
		||||
                Element="button"
 | 
			
		||||
                className={
 | 
			
		||||
                  'text-xs border-primary/10 ' +
 | 
			
		||||
                  (!isSortByModified
 | 
			
		||||
                    ? 'text-chalkboard-80 dark:text-chalkboard-40'
 | 
			
		||||
                    : '')
 | 
			
		||||
                }
 | 
			
		||||
                onClick={() =>
 | 
			
		||||
                  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
 | 
			
		||||
              </ActionButton>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </section>
 | 
			
		||||
        <section data-testid="home-section">
 | 
			
		||||
          <p className="my-4 text-sm text-chalkboard-80 dark:text-chalkboard-30">
 | 
			
		||||
            Loaded from{' '}
 | 
			
		||||
            <span className="text-chalkboard-90 dark:text-chalkboard-20">
 | 
			
		||||
            <Link
 | 
			
		||||
              to="settings?tab=user#projectDirectory"
 | 
			
		||||
              className="text-chalkboard-90 dark:text-chalkboard-20 underline underline-offset-2"
 | 
			
		||||
            >
 | 
			
		||||
              {settings.app.projectDirectory.current}
 | 
			
		||||
            </span>
 | 
			
		||||
            .{' '}
 | 
			
		||||
            <Link to="settings" className="underline underline-offset-2">
 | 
			
		||||
              Edit in settings
 | 
			
		||||
            </Link>
 | 
			
		||||
            .
 | 
			
		||||
          </p>
 | 
			
		||||
        </section>
 | 
			
		||||
        <section
 | 
			
		||||
          data-testid="home-section"
 | 
			
		||||
          className="flex-1 overflow-y-auto pr-2 pb-24"
 | 
			
		||||
        >
 | 
			
		||||
          {state.matches('Reading projects') ? (
 | 
			
		||||
            <Loading>Loading your Projects...</Loading>
 | 
			
		||||
          ) : (
 | 
			
		||||
            <>
 | 
			
		||||
              {projects.length > 0 ? (
 | 
			
		||||
                <ul className="grid w-full grid-cols-4 gap-4 my-8">
 | 
			
		||||
                <ul className="grid w-full grid-cols-4 gap-4">
 | 
			
		||||
                  {projects.sort(getSortFunction(sort)).map((project) => (
 | 
			
		||||
                    <ProjectCard
 | 
			
		||||
                      key={project.name}
 | 
			
		||||
@ -278,14 +301,6 @@ const Home = () => {
 | 
			
		||||
                  No Projects found, ready to make your first one?
 | 
			
		||||
                </p>
 | 
			
		||||
              )}
 | 
			
		||||
              <ActionButton
 | 
			
		||||
                Element="button"
 | 
			
		||||
                onClick={() => send('Create project')}
 | 
			
		||||
                iconStart={{ icon: faPlus, iconClassName: 'p-1 w-4' }}
 | 
			
		||||
                data-testid="home-new-file"
 | 
			
		||||
              >
 | 
			
		||||
                New project
 | 
			
		||||
              </ActionButton>
 | 
			
		||||
            </>
 | 
			
		||||
          )}
 | 
			
		||||
        </section>
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user