mirror of
https://github.com/misode/misode.github.io.git
synced 2026-04-26 08:26:51 +00:00
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:
31
src/app/components/generator/FileCreation.tsx
Normal file
31
src/app/components/generator/FileCreation.tsx
Normal 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>
|
||||
}
|
||||
29
src/app/components/generator/FileRenaming.tsx
Normal file
29
src/app/components/generator/FileRenaming.tsx
Normal 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>
|
||||
}
|
||||
96
src/app/components/generator/ProjectCreation.tsx
Normal file
96
src/app/components/generator/ProjectCreation.tsx
Normal 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>
|
||||
}
|
||||
27
src/app/components/generator/ProjectDeletion.tsx
Normal file
27
src/app/components/generator/ProjectDeletion.tsx
Normal 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>
|
||||
}
|
||||
122
src/app/components/generator/ProjectPanel.tsx
Normal file
122
src/app/components/generator/ProjectPanel.tsx
Normal 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>
|
||||
</>
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user