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

@@ -0,0 +1,31 @@
import { DataModel } from '@mcschema/core'
import { useState } from 'preact/hooks'
import { Analytics } from '../../Analytics'
import { useLocale, useProject } from '../../contexts'
import { Btn } from '../Btn'
import { TextInput } from '../forms'
import { Modal } from '../Modal'
interface Props {
model: DataModel,
id: string,
method: string,
onClose: () => void,
}
export function FileCreation({ model, id, method, onClose }: Props) {
const { locale } = useLocale()
const { projects, project, updateFile } = useProject()
const [fileId, setFileId] = useState('')
const doSave = () => {
Analytics.saveProjectFile(id, projects.length, project.files.length, method as any)
updateFile(id, undefined, { type: id, id: fileId, data: DataModel.unwrapLists(model.data) })
onClose()
}
return <Modal class="file-modal" onDismiss={onClose}>
<p>{locale('project.save_current_file')}</p>
<TextInput autofocus class="btn btn-input" value={fileId} onChange={setFileId} onEnter={doSave} onCancel={onClose} placeholder={locale('resource_location')} spellcheck={false} />
<Btn icon="file" label={locale('project.save')} onClick={doSave} />
</Modal>
}

View File

@@ -0,0 +1,29 @@
import { useState } from 'preact/hooks'
import { Analytics } from '../../Analytics'
import { useLocale, useProject } from '../../contexts'
import { Btn } from '../Btn'
import { TextInput } from '../forms'
import { Modal } from '../Modal'
interface Props {
id: string,
name: string,
onClose: () => void,
}
export function FileRenaming({ id, name, onClose }: Props) {
const { locale } = useLocale()
const { projects, project, updateFile } = useProject()
const [fileId, setFileId] = useState(name)
const doSave = () => {
Analytics.renameProjectFile(id, projects.length, project.files.length, 'menu')
updateFile(id, name, { type: id, id: fileId })
onClose()
}
return <Modal class="file-modal" onDismiss={onClose}>
<p>{locale('project.rename_file')}</p>
<TextInput autofocus class="btn btn-input" value={fileId} onChange={setFileId} onEnter={doSave} placeholder={locale('resource_location')} spellcheck={false} />
<Btn icon="pencil" label={locale('project.rename')} onClick={doSave} />
</Modal>
}

View File

@@ -0,0 +1,96 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
import { Btn, BtnMenu, FileUpload, Octicon, TextInput } from '..'
import config from '../../../config.json'
import type { Project } from '../../contexts'
import { disectFilePath, useLocale, useProject } from '../../contexts'
import type { VersionId } from '../../services'
import { DEFAULT_VERSION, parseSource } from '../../services'
import { message, readZip } from '../../Utils'
import { Modal } from '../Modal'
interface Props {
onClose: () => unknown,
}
export function ProjectCreation({ onClose }: Props) {
const { locale } = useLocale()
const { projects, createProject, changeProject, updateProject } = useProject()
const [name, setName] = useState('')
const [namespace, setNamespace] = useState('')
const [version, setVersion] = useState(DEFAULT_VERSION)
const [file, setFile] = useState<File | undefined>(undefined)
const [creating, setCreating] = useState(false)
const onUpload = (file: File) => {
if (file.type.match(/^application\/(x-)?zip(-compressed)?$/)) {
if (name.length === 0) {
setName(file.name
.replace(/\.zip$/, '')
.replaceAll(/[ _-]+/g, ' '))
}
setFile(file)
}
}
const projectUpdater = useRef(updateProject)
useEffect(() => {
projectUpdater.current = updateProject
}, [updateProject])
const onCreate = () => {
setCreating(true)
createProject(name, namespace || undefined, version)
changeProject(name)
if (file) {
readZip(file).then(async (entries) => {
const project: Partial<Project> = { files: [] }
await Promise.all(entries.map(async (entry) => {
const file = disectFilePath(entry[0])
if (file) {
try {
const data = await parseSource(entry[1], 'json')
project.files!.push({ ...file, data })
} catch (e) {
console.error(`Failed parsing ${file.type} ${file.id}: ${message(e)}`)
}
}
}))
projectUpdater.current(project)
onClose()
}).catch(() => {
onClose()
})
} else {
onClose()
}
}
const invalidName = useMemo(() => {
return projects.map(p => p.name.trim().toLowerCase()).includes(name.trim().toLowerCase())
}, [projects, name])
const invalidNamespace = useMemo(() => {
return !(namespace.length === 0 || namespace.match(/^(?:[a-z0-9._-]+:)?[a-z0-9/._-]+$/))
}, [namespace])
const versions = config.versions.map(v => v.id as VersionId).reverse()
return <Modal class="project-creation" onDismiss={onClose}>
<p>{locale('project.create')}</p>
<div class="input-group">
<TextInput autofocus class={`btn btn-input${!creating && (invalidName || name.length === 0) ? ' invalid': ''}`} placeholder={locale('project.name')} value={name} onChange={setName} />
{!creating && invalidName && <div class="status-icon danger tooltipped tip-e" aria-label={locale('project.name.already_exists')}>{Octicon.issue_opened}</div>}
</div>
<div class="input-group">
<TextInput class={`btn btn-input${!creating && invalidNamespace ? ' invalid' : ''}`} placeholder={locale('project.namespace')} value={namespace} onChange={setNamespace} />
{!creating && invalidNamespace && <div class="status-icon danger tooltipped tip-e" aria-label={locale('project.namespace.invalid')}>{Octicon.issue_opened}</div>}
</div>
<BtnMenu icon="tag" label={version} tooltip={locale('switch_version')} data-cy="version-switcher">
{versions.map(v =>
<Btn label={v} active={v === version} onClick={() => setVersion(v)} />
)}
</BtnMenu>
<FileUpload value={file} onChange={onUpload} label={locale('choose_zip_file')} accept=".zip"/>
<Btn icon="rocket" label="Create!" disabled={creating || invalidName || name.length === 0 || invalidNamespace} onClick={onCreate} />
</Modal>
}

View File

@@ -0,0 +1,27 @@
import { Analytics } from '../../Analytics'
import { useLocale, useProject } from '../../contexts'
import { Btn } from '../Btn'
import { Modal } from '../Modal'
interface Props {
onClose: () => void,
}
export function ProjectDeletion({ onClose }: Props) {
const { locale } = useLocale()
const { projects, project, deleteProject } = useProject()
const doSave = () => {
Analytics.deleteProject(projects.length, project.files.length, 'menu')
deleteProject(project.name)
onClose()
}
return <Modal class="file-modal" onDismiss={onClose}>
<p>{locale('project.delete_confirm.1', project.name)}</p>
<p><b>{locale('project.delete_confirm.2')}</b></p>
<div class="button-group">
<Btn icon="trashcan" label={locale('project.delete')} onClick={doSave} class="danger" />
<Btn label={locale('project.cancel')} onClick={onClose} />
</div>
</Modal>
}

View File

@@ -0,0 +1,122 @@
import type { DataModel } from '@mcschema/core'
import { useCallback, useMemo, useRef, useState } from 'preact/hooks'
import { Analytics } from '../../Analytics'
import { disectFilePath, DRAFT_PROJECT, getFilePath, useLocale, useProject } from '../../contexts'
import type { VersionId } from '../../services'
import { stringifySource } from '../../services'
import { Store } from '../../Store'
import { writeZip } from '../../Utils'
import { Btn } from '../Btn'
import { BtnMenu } from '../BtnMenu'
import type { EntryAction } from '../TreeView'
import { TreeView } from '../TreeView'
interface Props {
model: DataModel | undefined,
version: VersionId,
id: string,
onError: (message: string) => unknown,
onRename: (file: { type: string, id: string }) => unknown,
onCreate: () => unknown,
onDeleteProject: () => unknown,
}
export function ProjectPanel({ onRename, onCreate, onDeleteProject }: Props) {
const { locale } = useLocale()
const { projects, project, changeProject, file, openFile, updateFile } = useProject()
const [treeViewMode, setTreeViewMode] = useState(Store.getTreeViewMode())
const changeTreeViewMode = useCallback((mode: string) => {
Store.setTreeViewMode(mode)
Analytics.setTreeViewMode(mode)
setTreeViewMode(mode)
}, [])
const disectEntry = useCallback((entry: string) => {
if (treeViewMode === 'resources') {
const [type, id] = entry.split('/')
return {
type: type.replaceAll('\u2215', '/'),
id: id.replaceAll('\u2215', '/'),
}
}
return disectFilePath(entry)
}, [treeViewMode])
const entries = useMemo(() => project.files.flatMap(f => {
const path = getFilePath(f)
if (!path) return []
if (treeViewMode === 'resources') {
return [`${f.type.replaceAll('/', '\u2215')}/${f.id.replaceAll('/', '\u2215')}`]
}
return [path]
}), [treeViewMode, ...project.files])
const selected = useMemo(() => file && getFilePath(file), [file])
const selectFile = useCallback((entry: string) => {
const file = disectEntry(entry)
if (file) {
openFile(file.type, file.id)
}
}, [disectEntry])
const download = useRef<HTMLAnchorElement>(null)
const onDownload = async () => {
if (!download.current) return
const entries = project.files.flatMap(file => {
const path = getFilePath(file)
if (path === undefined) return []
return [[path, stringifySource(file.data)]] as [string, string][]
})
const url = await writeZip(entries)
download.current.setAttribute('href', url)
download.current.setAttribute('download', `${project.name.replaceAll(' ', '_')}.zip`)
download.current.click()
}
const actions = useMemo<EntryAction[]>(() => [
{
icon: 'pencil',
label: locale('project.rename_file'),
onAction: (e) => {
const file = disectEntry(e)
if (file) {
onRename(file)
}
},
},
{
icon: 'trashcan',
label: locale('project.delete_file'),
onAction: (e) => {
const file = disectEntry(e)
if (file) {
Analytics.deleteProjectFile(file.type, projects.length, project.files.length, 'menu')
updateFile(file.type, file.id, {})
}
},
},
], [disectEntry, updateFile, onRename])
return <>
<div class="project-controls">
<BtnMenu icon="chevron_down" label={project.name} tooltip={locale('switch_project')} tooltipLoc="se">
{projects.map(p => <Btn label={p.name} active={p.name === project.name} onClick={() => changeProject(p.name)} />)}
</BtnMenu>
<BtnMenu icon="kebab_horizontal" >
<Btn icon="file_zip" label={locale('project.download')} onClick={onDownload} />
<Btn icon="plus_circle" label={locale('project.new')} onClick={onCreate} />
<Btn icon={treeViewMode === 'resources' ? 'three_bars' : 'rows'} label={locale(treeViewMode === 'resources' ? 'project.show_file_paths' : 'project.show_resources')} onClick={() => changeTreeViewMode(treeViewMode === 'resources' ? 'files' : 'resources')} />
{project.name !== DRAFT_PROJECT.name && <Btn icon="trashcan" label={locale('project.delete')} onClick={onDeleteProject} />}
</BtnMenu>
</div>
<div class="file-view">
{entries.length === 0
? <span>{locale('project.no_files')}</span>
: <TreeView entries={entries} selected={selected} onSelect={selectFile} actions={actions} />}
</div>
<a ref={download} style="display: none;"></a>
</>
}

View File

@@ -1,48 +1,14 @@
import { DataModel } from '@mcschema/core'
import yaml from 'js-yaml'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import { Btn, BtnMenu } from '..'
import { useLocale } from '../../contexts'
import { useModel } from '../../hooks'
import { getOutput } from '../../schema/transformOutput'
import type { BlockStateRegistry } from '../../services'
import { getSourceFormats, getSourceIndent, getSourceIndents, parseSource, stringifySource } from '../../services'
import { Store } from '../../Store'
import { message } from '../../Utils'
const INDENT: Record<string, number | string | undefined> = {
'2_spaces': 2,
'4_spaces': 4,
tabs: '\t',
minified: undefined,
}
let commentJson: typeof import('comment-json') | null = null
const FORMATS: Record<string, {
parse: (v: string) => Promise<any>,
stringify: (v: any, indentation: string | number | undefined) => string,
}> = {
json: {
parse: async (v) => {
try {
return JSON.parse(v)
} catch (e) {
commentJson = await import('comment-json')
return commentJson.parse(v)
}
},
stringify: (v, i) => (commentJson ?? JSON).stringify(v, null, i) + '\n',
},
yaml: {
parse: async (v) => yaml.load(v),
stringify: (v, i) => yaml.dump(v, {
flowLevel: i === undefined ? 0 : -1,
indent: typeof i === 'string' ? 4 : i,
}),
},
}
interface Editor {
getValue(): string
setValue(value: string): void
@@ -75,7 +41,7 @@ export function SourcePanel({ name, model, blockStates, doCopy, doDownload, doIm
const getSerializedOutput = useCallback((model: DataModel, blockStates: BlockStateRegistry) => {
const data = getOutput(model, blockStates)
return FORMATS[format].stringify(data, INDENT[indent])
return stringifySource(data, format, indent)
}, [indent, format])
useEffect(() => {
@@ -102,7 +68,7 @@ export function SourcePanel({ name, model, blockStates, doCopy, doDownload, doIm
const value = editor.current.getValue()
if (value.length === 0) return
try {
const data = await FORMATS[format].parse(value)
const data = await parseSource(value, format)
model?.reset(DataModel.wrapLists(data), false)
} catch (e) {
if (e instanceof Error) {
@@ -149,7 +115,7 @@ export function SourcePanel({ name, model, blockStates, doCopy, doDownload, doIm
},
configure(indent, format) {
braceEditor.setOption('useSoftTabs', indent !== 'tabs')
braceEditor.setOption('tabSize', indent === 'tabs' ? 4 : INDENT[indent])
braceEditor.setOption('tabSize', indent === 'tabs' ? 4 : getSourceIndent(indent))
braceEditor.getSession().setMode(`ace/mode/${format}`)
},
select() {
@@ -233,12 +199,12 @@ export function SourcePanel({ name, model, blockStates, doCopy, doDownload, doIm
return <>
<div class="controls source-controls">
<BtnMenu icon="gear" tooltip={locale('output_settings')} data-cy="source-controls">
{Object.entries(INDENT).map(([key]) =>
{getSourceIndents().map(key =>
<Btn label={locale(`indentation.${key}`)} active={indent === key}
onClick={() => changeIndent(key)}/>
)}
<hr />
{Object.keys(FORMATS).map(key =>
{getSourceFormats().map(key =>
<Btn label={locale(`format.${key}`)} active={format === key}
onClick={() => changeFormat(key)} />)}
<hr />

View File

@@ -1,3 +1,8 @@
export * from './FileCreation'
export * from './FileRenaming'
export * from './PreviewPanel'
export * from './ProjectCreation'
export * from './ProjectDeletion'
export * from './ProjectPanel'
export * from './SourcePanel'
export * from './Tree'