* Add file save UI and drafts project

* Fix build

* Create SearchList component as abstraction

* Add project page and file tree view

* Create Locale context

* Create Theme context

* Create Version context

* Create Title context

* Create Project context

* Store current file in project context

* Fix issues when renaming file and implement deleting

* Style improvements

* Make all project strings translatable

* Fix z-index
This commit is contained in:
Misode
2022-01-18 01:02:19 +01:00
committed by GitHub
parent cd318dc795
commit c6c52ca41a
39 changed files with 958 additions and 373 deletions

View File

@@ -0,0 +1,89 @@
import type { ComponentChildren } from 'preact'
import { createContext } from 'preact'
import { useCallback, useContext, useEffect, useState } from 'preact/hooks'
import config from '../../config.json'
import English from '../../locales/en.json'
import { Analytics } from '../Analytics'
import { Store } from '../Store'
interface Locale {
lang: string,
locale: (key: string, ...params: string[]) => string,
changeLanguage: (lang: string) => unknown,
}
const Locale = createContext<Locale>({
lang: 'none',
locale: key => key,
changeLanguage: () => {},
})
export const Locales: {
[key: string]: {
[key: string]: string,
},
} = {
fallback: English,
}
export function localize(lang: string, key: string, ...params: string[]) {
const value: string | undefined = Locales[lang]?.[key]
?? Locales.en?.[key] ?? Locales.fallback[key] ?? key
return resolveLocaleParams(value, params)
}
function resolveLocaleParams(value: string, params?: string[]): string {
return value.replace(/%\d+%/g, match => {
const index = parseInt(match.slice(1, -1))
return params?.[index] !== undefined ? params[index] : match
})
}
async function loadLocale(language: string) {
if (Locales[language]) return
const langConfig = config.languages.find(lang => lang.code === language)
if (!langConfig) return
const data = await import(`../../locales/${language}.json`)
const schema = langConfig.schemas !== false
&& await import(`../../../node_modules/@mcschema/locales/src/${language}.json`)
Locales[language] = { ...data.default, ...schema.default }
}
export function useLocale() {
return useContext(Locale)
}
export function LocaleProvider({ children }: { children: ComponentChildren }) {
const [lang, setLanguage] = useState('none')
const locale = useCallback((key: string, ...params: string[]) => {
return localize(lang, key, ...params)
}, [lang])
const changeLanguage = useCallback(async (lang: string) => {
await loadLocale(lang)
Analytics.setLanguage(lang)
Store.setLanguage(lang)
setLanguage(lang)
}, [])
useEffect(() => {
(async () => {
const target = Store.getLanguage()
await Promise.all([
loadLocale('en'),
...(target !== 'en' ? [loadLocale(target)] : []),
])
setLanguage(target)
})()
}, [])
const value: Locale = {
lang,
locale: locale,
changeLanguage,
}
return <Locale.Provider value={value}>
{children}
</Locale.Provider>
}

View File

@@ -0,0 +1,129 @@
import type { ComponentChildren } from 'preact'
import { createContext } from 'preact'
import { route } from 'preact-router'
import { useCallback, useContext, useMemo, useState } from 'preact/hooks'
import config from '../../config.json'
import type { VersionId } from '../services'
import { Store } from '../Store'
import { cleanUrl } from '../Utils'
export type Project = {
name: string,
namespace: string,
version?: VersionId,
files: ProjectFile[],
}
export const DRAFT_PROJECT: Project = {
name: 'Drafts',
namespace: 'draft',
files: [],
}
export type ProjectFile = {
type: string,
id: string,
data: any,
}
interface ProjectContext {
project: Project,
file?: ProjectFile,
changeProject: (name: string) => unknown,
updateProject: (project: Partial<Project>) => unknown,
updateFile: (type: string, id: string | undefined, file: Partial<ProjectFile>) => boolean,
openFile: (type: string, id: string) => unknown,
closeFile: () => unknown,
}
const Project = createContext<ProjectContext>({
project: DRAFT_PROJECT,
changeProject: () => {},
updateProject: () => {},
updateFile: () => false,
openFile: () => {},
closeFile: () => {},
})
export function useProject() {
return useContext(Project)
}
export function ProjectProvider({ children }: { children: ComponentChildren }) {
const [projects, setProjects] = useState<Project[]>(Store.getProjects())
const [projectName, setProjectName] = useState<string>(DRAFT_PROJECT.name)
const project = useMemo(() => {
return projects.find(p => p.name === projectName) ?? DRAFT_PROJECT
}, [projects, projectName])
const [fileId, setFileId] = useState<[string, string] | undefined>(undefined)
const file = useMemo(() => {
if (!fileId) return undefined
return project.files.find(f => f.type === fileId[0] && f.id === fileId[1])
}, [project, fileId])
const changeProjects = useCallback((projects: Project[]) => {
Store.setProjects(projects)
setProjects(projects)
}, [])
const updateProject = useCallback((edits: Partial<Project>) => {
changeProjects(projects.map(p => p.name === projectName ? { ...p, ...edits } : p))
}, [projects, projectName])
const updateFile = useCallback((type: string, id: string | undefined, edits: Partial<ProjectFile>) => {
if (!edits.id) { // remove
updateProject({ files: project.files.filter(f => f.type !== type || f.id !== id) })
} else {
const newId = edits.id.includes(':') ? edits.id : `${project.namespace}:${edits.id}`
const exists = project.files.some(f => f.type === type && f.id === newId)
if (!id) { // create
if (exists) return false
updateProject({ files: [...project.files, { type, id: newId, data: edits.data ?? {} } ]})
setFileId([type, newId])
} else { // rename or update data
if (file?.id === id && id !== newId && exists) {
return false
}
updateProject({ files: project.files.map(f => f.type === type && f.id === id ? { ...f, ...edits, id: newId } : f)})
if (file?.id === id) setFileId([type, newId])
}
}
return true
}, [updateProject, project, file])
const openFile = useCallback((type: string, id: string) => {
const gen = config.generators.find(g => g.id === type || g.path === type)
if (!gen) {
throw new Error(`Cannot find generator of type ${type}`)
}
setFileId([gen.id, id])
route(cleanUrl(gen.url))
}, [])
const closeFile = useCallback(() => {
setFileId(undefined)
}, [])
const value: ProjectContext = {
project,
file,
changeProject: setProjectName,
updateProject,
updateFile,
openFile,
closeFile,
}
return <Project.Provider value={value}>
{children}
</Project.Provider>
}
export function getFilePath(file: ProjectFile) {
const [namespace, id] = file.id.includes(':') ? file.id.split(':') : ['minecraft', file.id]
const gen = config.generators.find(g => g.id === file.type)
if (!gen) {
throw new Error(`Cannot find generator of type ${file.type}`)
}
return `data/${namespace}/${gen.path ?? gen.id}/${id}`
}

View File

@@ -0,0 +1,41 @@
import type { ComponentChildren } from 'preact'
import { createContext } from 'preact'
import { useCallback, useContext, useEffect, useState } from 'preact/hooks'
import { Analytics } from '../Analytics'
import { Store } from '../Store'
interface Theme {
theme: string,
changeTheme: (theme: string) => unknown,
}
const Theme = createContext<Theme>({
theme: 'dark',
changeTheme: () => {},
})
export function useTheme() {
return useContext(Theme)
}
export function ThemeProvider({ children }: { children: ComponentChildren }) {
const [theme, setTheme] = useState(Store.getTheme())
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
}, [theme])
const changeTheme = useCallback((theme: string) => {
Analytics.setTheme(theme)
Store.setTheme(theme)
setTheme(theme)
}, [])
const value: Theme = {
theme,
changeTheme,
}
return <Theme.Provider value={value}>
{children}
</Theme.Provider>
}

View File

@@ -0,0 +1,48 @@
import type { ComponentChildren } from 'preact'
import { createContext } from 'preact'
import { useCallback, useContext, useEffect, useState } from 'preact/hooks'
import { useLocale } from '.'
import config from '../../config.json'
import type { VersionId } from '../services'
const VERSIONS_IN_TITLE = 3
interface Title {
title: string,
changeTitle: (title: string, versions?: VersionId[]) => unknown,
}
const Title = createContext<Title>({
title: '',
changeTitle: () => {},
})
export function useTitle(title?: string, versions?: VersionId[]) {
const context = useContext(Title)
useEffect(() => {
if (title) {
context.changeTitle(title, versions)
}
}, [title, versions])
return context
}
export function TitleProvider({ children }: { children: ComponentChildren }) {
const { locale } = useLocale()
const [title, setTitle] = useState<string>(locale('title.home'))
const changeTitle = useCallback((title: string, versions?: VersionId[]) => {
versions ??= config.versions.map(v => v.id as VersionId)
const titleVersions = versions.slice(versions.length - VERSIONS_IN_TITLE)
document.title = `${title} Minecraft ${titleVersions.join(', ')}`
setTitle(title)
}, [])
const value = {
title,
changeTitle,
}
return <Title.Provider value={value}>
{children}
</Title.Provider>
}

View File

@@ -0,0 +1,54 @@
import type { ComponentChildren } from 'preact'
import { createContext } from 'preact'
import { getCurrentUrl } from 'preact-router'
import { useCallback, useContext, useEffect, useState } from 'preact/hooks'
import { Analytics } from '../Analytics'
import type { VersionId } from '../services'
import { VersionIds } from '../services'
import { Store } from '../Store'
import { getSearchParams, setSeachParams } from '../Utils'
const VERSION_PARAM = 'version'
interface Version {
version: VersionId,
changeVersion: (version: VersionId) => unknown,
}
const Version = createContext<Version>({
version: '1.18',
changeVersion: () => {},
})
export function useVersion() {
return useContext(Version)
}
export function VersionProvider({ children }: { children: ComponentChildren }) {
const [version, setVersion] = useState<VersionId>(Store.getVersion())
const searchParams = getSearchParams(getCurrentUrl())
const targetVersion = searchParams.get(VERSION_PARAM)
useEffect(() => {
if (VersionIds.includes(targetVersion as VersionId) && version !== targetVersion) {
setVersion(targetVersion as VersionId)
}
}, [version, targetVersion])
const changeVersion = useCallback((version: VersionId) => {
if (getSearchParams(getCurrentUrl()).has(VERSION_PARAM)) {
setSeachParams({ version })
}
Analytics.setVersion(version)
Store.setVersion(version)
setVersion(version)
}, [])
const value: Version = {
version,
changeVersion,
}
return <Version.Provider value={value}>
{children}
</Version.Provider>
}

View File

@@ -0,0 +1,5 @@
export * from './Locale'
export * from './Project'
export * from './Theme'
export * from './Title'
export * from './Version'