Project tree view and creation (#203)

* Implement creating and importing new projects

* Add downloading a zip of a project

* Project validation (WIP)

* Add project side panel, remove project pages

* Project file saving

* Add file tree actions to rename and delete

* Fix file creation auto focus

* Add button to save file from menu

* Add project creation

* Fix specificity on version switcher button

* Update default version to 1.19

* List project files by type, remember project and delete project
This commit is contained in:
Misode
2022-06-14 16:48:55 +02:00
committed by GitHub
parent 4942729e7c
commit 90eac0f9b8
39 changed files with 1132 additions and 267 deletions

View File

@@ -3,8 +3,8 @@ import { getCurrentUrl, route } from 'preact-router'
import { useEffect, useErrorBoundary, useMemo, useRef, useState } from 'preact/hooks'
import config from '../../config.json'
import { Analytics } from '../Analytics'
import { Ad, Btn, BtnMenu, ErrorPanel, Footer, HasPreview, Octicon, PreviewPanel, SearchList, SourcePanel, TextInput, Tree, VersionSwitcher } from '../components'
import { useLocale, useProject, useTitle, useVersion } from '../contexts'
import { Ad, Btn, BtnMenu, ErrorPanel, FileCreation, FileRenaming, Footer, HasPreview, Octicon, PreviewPanel, ProjectCreation, ProjectDeletion, ProjectPanel, SearchList, SourcePanel, TextInput, Tree, VersionSwitcher } from '../components'
import { DRAFT_PROJECT, useLocale, useProject, useTitle, useVersion } from '../contexts'
import { AsyncCancel, useActiveTimeout, useAsync, useModel, useSearchParam } from '../hooks'
import { getOutput } from '../schema/transformOutput'
import type { VersionId } from '../services'
@@ -20,7 +20,7 @@ interface Props {
export function Generator({}: Props) {
const { locale } = useLocale()
const { version, changeVersion, changeTargetVersion } = useVersion()
const { projects, project, file, updateFile, openFile, closeFile } = useProject()
const { projects, project, file, updateProject, updateFile } = useProject()
const [error, setError] = useState<Error | string | null>(null)
const [errorBoundary, errorRetry] = useErrorBoundary()
if (errorBoundary) {
@@ -91,6 +91,12 @@ export function Generator({}: Props) {
}
Analytics.openSnippet(gen.id, sharedSnippetId, version)
data = snippet.data
} else if (file) {
if (project.version && project.version !== version) {
changeVersion(project.version, false)
return AsyncCancel
}
data = file.data
}
const [model, blockStates] = await Promise.all([
getModel(version, gen.id),
@@ -102,65 +108,24 @@ export function Generator({}: Props) {
}
Analytics.setGenerator(gen.id)
return { model, blockStates }
}, [gen.id, version, sharedSnippetId, currentPreset])
}, [gen.id, version, sharedSnippetId, currentPreset, project.name, file?.id])
const model = value?.model
const blockStates = value?.blockStates
const [dirty, setDirty] = useState(false)
useModel(model, () => {
useModel(model, model => {
if (!ignoreChange.current) {
setCurrentPreset(undefined, true)
setSharedSnippetId(undefined, true)
}
ignoreChange.current = false
Store.setBackup(gen.id, DataModel.unwrapLists(model?.data))
setError(null)
setDirty(true)
}, [gen.id, setCurrentPreset, setSharedSnippetId])
const [fileRename, setFileRename] = useState('')
const [fileSaved, doSave] = useActiveTimeout()
const [fileError, doFileError] = useActiveTimeout()
const doFileRename = () => {
if (fileRename !== file?.id && fileRename && model && blockStates) {
if (file && model && blockStates) {
const data = getOutput(model, blockStates)
const success = updateFile(gen.id, file?.id, { id: fileRename, data })
if (success) {
doSave()
} else {
doFileError()
if (file) {
setFileRename(file?.id)
}
}
} else if (file) {
setFileRename(file?.id)
updateFile(gen.id, file.id, { id: file.id, data })
}
}
const deleteFile = () => {
if (file) {
updateFile(gen.id, file.id, {})
}
}
useEffect(() => {
if (file) {
setFileRename(file.id)
}
}, [file])
useEffect(() => {
if (model) {
setFileRename(file?.id ?? '')
if (file && gen.id === file.type) {
model.reset(DataModel.wrapLists(file.data))
}
setDirty(false)
}
}, [file, model])
ignoreChange.current = false
Store.setBackup(gen.id, DataModel.unwrapLists(model.data))
setError(null)
}, [gen.id, setCurrentPreset, setSharedSnippetId, blockStates, file?.id])
const reset = () => {
Analytics.resetGenerator(gen.id, model?.historyIndex ?? 1, 'menu')
@@ -188,14 +153,9 @@ export function Generator({}: Props) {
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === 's') {
setFileSaving('hotkey')
e.preventDefault()
if (model && blockStates && file) {
Analytics.saveProjectFile(gen.id, project.files.length, projects.length, 'hotkey')
const data = getOutput(model, blockStates)
updateFile(gen.id, file?.id, { id: file?.id, data })
setDirty(false)
doSave()
}
e.stopPropagation()
}
}
useEffect(() => {
@@ -242,6 +202,9 @@ export function Generator({}: Props) {
const selectVersion = (version: VersionId) => {
setSharedSnippetId(undefined, true)
changeVersion(version)
if (project.name !== DRAFT_PROJECT.name && project.version !== version) {
updateProject({ version })
}
}
const [shareUrl, setShareUrl] = useState<string | undefined>(undefined)
@@ -339,43 +302,43 @@ export function Generator({}: Props) {
}
}
const [projectShown, setProjectShown] = useState(window.innerWidth > 600)
const [projectCreating, setProjectCreating] = useState(false)
const [projectDeleting, setprojectDeleting] = useState(false)
const [fileSaving, setFileSaving] = useState<string | undefined>(undefined)
const [fileRenaming, setFileRenaming] = useState<{ type: string, id: string } | undefined>(undefined)
const toggleProject = () => {
if (projectShown) {
Analytics.hideProject(gen.id, projects.length, project.files.length, 'menu')
} else {
Analytics.showProject(gen.id, projects.length, project.files.length, 'menu')
}
setProjectShown(!projectShown)
}
return <>
<main class={previewShown ? 'has-preview' : ''}>
<main class={`generator${previewShown ? ' has-preview' : ''}${projectShown ? ' has-project' : ''}`}>
{!gen.partner && <Ad id="data-pack-generator" type="text" />}
<div class="controls">
<div class={`project-controls ${file && 'has-file'}`}>
<div class="btn-row">
<BtnMenu icon="repo" label={project.name} relative={false}>
<Btn icon="arrow_left" label={locale('project.go_to')} onClick={() => route('/project')} />
{file && <Btn icon="file" label={locale('project.new_file')} onClick={closeFile} />}
{backup !== undefined && <Btn icon="history" label={locale('restore_backup')} onClick={loadBackup} />}
<SearchList searchPlaceholder={locale(project.name === 'Drafts' ? 'project.search_drafts' : 'project.search')} noResults={locale('project.no_files')} values={project.files.filter(f => f.type === gen.id).map(f => f.id)} onSelect={(id) => openFile(gen.id, id)} />
</BtnMenu>
<TextInput class="btn btn-input" placeholder={locale('project.unsaved_file')} value={fileRename} onChange={setFileRename} onEnter={doFileRename} onBlur={doFileRename} />
{file && <Btn icon="trashcan" tooltip={locale('project.delete_file')} onClick={deleteFile} />}
</div>
{dirty ? <div class="status-icon">{Octicon.dot_fill}</div>
: fileSaved ? <div class="status-icon active">{Octicon.check}</div>
: fileError && <div class="status-icon danger">{Octicon.x}</div> }
</div>
<div class="generator-controls">
<Btn icon="upload" label={locale('import')} onClick={importSource} />
<BtnMenu icon="archive" label={locale('presets')} relative={false}>
<SearchList searchPlaceholder={locale('search')} noResults={locale('no_presets')} values={presets} onSelect={selectPreset}/>
</BtnMenu>
<VersionSwitcher value={version} onChange={selectVersion} allowed={allowedVersions} />
<BtnMenu icon="kebab_horizontal" tooltip={locale('more')}>
<Btn icon="history" label={locale('reset')} onClick={reset} />
<Btn icon="arrow_left" label={locale('undo')} onClick={undo} />
<Btn icon="arrow_right" label={locale('redo')} onClick={redo} />
</BtnMenu>
</div>
<div class="controls generator-controls">
<Btn icon="upload" label={locale('import')} onClick={importSource} />
<BtnMenu icon="archive" label={locale('presets')} relative={false}>
<SearchList searchPlaceholder={locale('search')} noResults={locale('no_presets')} values={presets} onSelect={selectPreset}/>
</BtnMenu>
<VersionSwitcher value={version} onChange={selectVersion} allowed={allowedVersions} />
<BtnMenu icon="kebab_horizontal" tooltip={locale('more')}>
<Btn icon="history" label={locale('reset_default')} onClick={reset} />
{backup !== undefined && <Btn icon="history" label={locale('restore_backup')} onClick={loadBackup} />}
<Btn icon="arrow_left" label={locale('undo')} onClick={undo} />
<Btn icon="arrow_right" label={locale('redo')} onClick={redo} />
<Btn icon="file" label={locale('project.save')} onClick={() => setFileSaving('menu')} />
</BtnMenu>
</div>
{error && <ErrorPanel error={error} onDismiss={() => setError(null)} />}
<Tree {...{model, version, blockStates}} onError={setError} />
<Footer donate={!gen.partner} />
</main>
<div class="popup-actions" style={`--offset: -${8 + actionsShown * 50}px;`}>
<div class="popup-actions right-actions" style={`--offset: -${8 + actionsShown * 50}px;`}>
<div class={`popup-action action-preview${hasPreview ? ' shown' : ''} tooltipped tip-nw`} aria-label={locale(previewShown ? 'hide_preview' : 'show_preview')} onClick={togglePreview}>
{previewShown ? Octicon.x_circle : Octicon.play}
</div>
@@ -400,7 +363,19 @@ export function Generator({}: Props) {
</div>
<div class={`popup-share${shareShown ? ' shown' : ''}`}>
<TextInput value={shareUrl} readonly />
<Btn icon={shareCopyActive ? 'check' : 'clippy'} onClick={copySharedId} tooltip={locale(shareCopyActive ? 'copied' : 'copy_share')} tooltipLoc="nw" active={shareCopyActive} showTooltip={shareCopyActive} />
<Btn icon={shareCopyActive ? 'check' : 'clippy'} onClick={copySharedId} tooltip={locale(shareCopyActive ? 'copied' : 'copy_share')} tooltipLoc="nw" active={shareCopyActive} />
</div>
<div class="popup-actions left-actions" style="--offset: 50px;">
<div class={'popup-action action-project shown tooltipped tip-ne'} aria-label={locale(projectShown ? 'hide_project' : 'show_project')} onClick={toggleProject}>
{projectShown ? Octicon.chevron_left : Octicon.repo}
</div>
</div>
<div class={`popup-project${projectShown ? ' shown' : ''}`}>
<ProjectPanel {...{model, version, id: gen.id}} onError={setError} onDeleteProject={() => setprojectDeleting(true)} onRename={setFileRenaming} onCreate={() => setProjectCreating(true)} />
</div>
{projectCreating && <ProjectCreation onClose={() => setProjectCreating(false)} />}
{projectDeleting && <ProjectDeletion onClose={() => setprojectDeleting(false)} />}
{model && fileSaving && <FileCreation id={gen.id} model={model} method={fileSaving} onClose={() => setFileSaving(undefined)} />}
{fileRenaming && <FileRenaming id={fileRenaming.type } name={fileRenaming.id} onClose={() => setFileRenaming(undefined)} />}
</>
}