mirror of
https://github.com/misode/misode.github.io.git
synced 2026-04-26 00:16:51 +00:00
Projects (#192)
* 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:
89
src/app/contexts/Locale.tsx
Normal file
89
src/app/contexts/Locale.tsx
Normal 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>
|
||||
}
|
||||
129
src/app/contexts/Project.tsx
Normal file
129
src/app/contexts/Project.tsx
Normal 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}`
|
||||
}
|
||||
41
src/app/contexts/Theme.tsx
Normal file
41
src/app/contexts/Theme.tsx
Normal 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>
|
||||
}
|
||||
48
src/app/contexts/Title.tsx
Normal file
48
src/app/contexts/Title.tsx
Normal 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>
|
||||
}
|
||||
54
src/app/contexts/Version.tsx
Normal file
54
src/app/contexts/Version.tsx
Normal 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>
|
||||
}
|
||||
5
src/app/contexts/index.ts
Normal file
5
src/app/contexts/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './Locale'
|
||||
export * from './Project'
|
||||
export * from './Theme'
|
||||
export * from './Title'
|
||||
export * from './Version'
|
||||
Reference in New Issue
Block a user