Added react i18next.

This commit is contained in:
SamTV12345 2024-03-09 14:04:00 +01:00
parent 6f440a13cd
commit 61a95c1630
13 changed files with 449 additions and 32 deletions

View file

@ -11,8 +11,12 @@
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-toast": "^1.1.5",
"i18next": "^23.10.1",
"i18next-browser-languagedetector": "^7.2.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^14.1.0",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"zustand": "^4.5.2" "zustand": "^4.5.2"
}, },
@ -28,6 +32,7 @@
"socket.io-client": "^4.7.4", "socket.io-client": "^4.7.4",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite": "^5.1.4", "vite": "^5.1.4",
"vite-plugin-static-copy": "^1.0.1",
"vite-plugin-svgr": "^4.2.0" "vite-plugin-svgr": "^4.2.0"
} }
} }

View file

@ -5,11 +5,17 @@ import {isJSONClean} from './utils/utils.ts'
import {NavLink, Outlet} from "react-router-dom"; import {NavLink, Outlet} from "react-router-dom";
import {useStore} from "./store/store.ts"; import {useStore} from "./store/store.ts";
import {LoadingScreen} from "./utils/LoadingScreen.tsx"; import {LoadingScreen} from "./utils/LoadingScreen.tsx";
import {ToastDialog} from "./utils/Toast.tsx";
import {useTranslation} from "react-i18next";
export const App = ()=> { export const App = ()=> {
const setSettings = useStore(state => state.setSettings); const setSettings = useStore(state => state.setSettings);
const {t} = useTranslation()
useEffect(() => { useEffect(() => {
document.title = t('admin.page-title')
useStore.getState().setShowLoading(true); useStore.getState().setShowLoading(true);
const settingSocket = connect('http://localhost:9001/settings', { const settingSocket = connect('http://localhost:9001/settings', {
transports: ['websocket'], transports: ['websocket'],
@ -26,13 +32,18 @@ export const App = ()=> {
settingSocket.on('connect', () => { settingSocket.on('connect', () => {
useStore.getState().setSettingsSocket(settingSocket); useStore.getState().setSettingsSocket(settingSocket);
useStore.getState().setShowLoading(false)
settingSocket.emit('load'); settingSocket.emit('load');
console.log('connected'); console.log('connected');
}); });
settingSocket.on('disconnect', (reason) => { settingSocket.on('disconnect', (reason) => {
// The settingSocket.io client will automatically try to reconnect for all reasons other than "io // The settingSocket.io client will automatically try to reconnect for all reasons other than "io
// server disconnect". // server disconnect".
if (reason === 'io server disconnect') settingSocket.connect(); useStore.getState().setShowLoading(true)
if (reason === 'io server disconnect') {
settingSocket.connect();
}
}); });
settingSocket.on('settings', (settings) => { settingSocket.on('settings', (settings) => {
@ -63,6 +74,7 @@ export const App = ()=> {
return <div id="wrapper"> return <div id="wrapper">
<LoadingScreen/> <LoadingScreen/>
<ToastDialog/>
<div className="menu"> <div className="menu">
<h1>Etherpad</h1> <h1>Etherpad</h1>
<ul> <ul>

View file

@ -14,10 +14,6 @@ html, body, #root {
box-sizing: inherit; box-sizing: inherit;
} }
body {
overflow: hidden;
}
body { body {
margin: 0; margin: 0;
color: #333; color: #333;
@ -64,7 +60,7 @@ div.innerwrapper-err {
box-shadow: 0px 1px 10px rgba(0, 0, 0, 0.2); box-shadow: 0px 1px 10px rgba(0, 0, 0, 0.2);
margin: auto; margin: auto;
max-width: 1150px; max-width: 1150px;
min-height: 101%;/*always display a scrollbar*/ min-height: 100%;/*always display a scrollbar*/
} }
@ -357,3 +353,107 @@ pre {
font-size: 2em; font-size: 2em;
margin-bottom: 20px; margin-bottom: 20px;
} }
.ToastViewport {
position: fixed;
top: 10px;
right: 20px;
display: flex;
flex-direction: column;
gap: 10px;
width: 390px;
max-width: 100vw;
margin: 0;
list-style: none;
z-index: 2147483647;
outline: none;
}
.ToastRootSuccess {
background-color: lawngreen;
}
.ToastRootFailure {
background-color: red;
}
.ToastRootFailure > .ToastTitle {
color: white;
}
.ToastRoot {
border-radius: 20px;
box-shadow: hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px;
padding: 15px;
display: grid;
grid-template-areas: 'title action' 'description action';
grid-template-columns: auto max-content;
column-gap: 15px;
align-items: center;
}
.ToastRoot[data-state='open'] {
animation: slideIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
.ToastRoot[data-state='closed'] {
animation: hide 100ms ease-in;
}
.ToastRoot[data-swipe='move'] {
transform: translateX(var(--radix-toast-swipe-move-x));
}
.ToastRoot[data-swipe='cancel'] {
transform: translateX(0);
transition: transform 200ms ease-out;
}
.ToastRoot[data-swipe='end'] {
animation: swipeOut 100ms ease-out;
}
@keyframes hide {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes slideIn {
from {
transform: translateX(calc(100% + var(--viewport-padding)));
}
to {
transform: translateX(0);
}
}
@keyframes swipeOut {
from {
transform: translateX(var(--radix-toast-swipe-end-x));
}
to {
transform: translateX(calc(100% + var(--viewport-padding)));
}
}
.ToastTitle {
grid-area: title;
margin-bottom: 5px;
font-weight: 500;
color: var(--slate-12);
padding: 10px;
font-size: 15px;
}
.ToastDescription {
grid-area: description;
margin: 0;
color: var(--slate-11);
font-size: 13px;
line-height: 1.3;
}
.ToastAction {
grid-area: action;
}

View file

@ -0,0 +1,48 @@
import i18n from 'i18next'
import {initReactI18next} from "react-i18next";
import LanguageDetector from 'i18next-browser-languagedetector'
import { BackendModule } from 'i18next';
const LazyImportPlugin: BackendModule = {
type: 'backend',
init: function (services, backendOptions, i18nextOptions) {
},
read: async function (language, namespace, callback) {
console.log(import.meta.env.BASE_URL+`/locales/${language}.json`)
const localeJSON = await fetch(import.meta.env.BASE_URL+`/locales/${language}.json`)
let json;
try {
json = JSON.parse(await localeJSON.text())
} catch(e) {
callback(true, null);
}
callback(null, json);
},
save: function (language, namespace, data) {
},
create: function (languages, namespace, key, fallbackValue) {
/* save the missing translation */
},
};
i18n
.use(LanguageDetector)
.use(LazyImportPlugin)
.use(initReactI18next)
.init(
{
backend:{
loadPath: import.meta.env.BASE_URL+'/locales/{{lng}}-{{ns}}.json'
},
fallbackLng: 'en'
}
)
export default i18n

View file

@ -7,6 +7,9 @@ import {HomePage} from "./pages/HomePage.tsx";
import {SettingsPage} from "./pages/SettingsPage.tsx"; import {SettingsPage} from "./pages/SettingsPage.tsx";
import {LoginScreen} from "./pages/LoginScreen.tsx"; import {LoginScreen} from "./pages/LoginScreen.tsx";
import {HelpPage} from "./pages/HelpPage.tsx"; import {HelpPage} from "./pages/HelpPage.tsx";
import * as Toast from '@radix-ui/react-toast'
import {I18nextProvider} from "react-i18next";
import i18n from "./localization/i18n.ts";
const router = createBrowserRouter(createRoutesFromElements( const router = createBrowserRouter(createRoutesFromElements(
<><Route element={<App/>}> <><Route element={<App/>}>
@ -17,12 +20,17 @@ const router = createBrowserRouter(createRoutesFromElements(
</Route><Route path="/login"> </Route><Route path="/login">
<Route index element={<LoginScreen/>}/> <Route index element={<LoginScreen/>}/>
</Route></> </Route></>
)) ), {
basename: import.meta.env.BASE_URL
})
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<RouterProvider router={router}> <I18nextProvider i18n={i18n}>
</RouterProvider> <Toast.Provider>
<RouterProvider router={router}/>
</Toast.Provider>
</I18nextProvider>
</React.StrictMode>, </React.StrictMode>,
) )

View file

@ -1,48 +1,161 @@
import {useStore} from "../store/store.ts"; import {useStore} from "../store/store.ts";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {PluginDef} from "./Plugin.ts"; import {InstalledPlugin, PluginDef, SearchParams} from "./Plugin.ts";
import {useDebounce} from "../utils/useDebounce.ts";
export const HomePage = () => { export const HomePage = () => {
const pluginsSocket = useStore(state=>state.pluginsSocket) const pluginsSocket = useStore(state=>state.pluginsSocket)
const [limit, setLimit] = useState(20)
const [offset, setOffset] = useState(0)
const [plugins,setPlugins] = useState<PluginDef[]>([]) const [plugins,setPlugins] = useState<PluginDef[]>([])
const [installedPlugins, setInstalledPlugins] = useState<InstalledPlugin[]>([])
const [searchParams, setSearchParams] = useState<SearchParams>({
offset: 0,
limit: 99999,
sortBy: 'name',
sortDir: 'asc',
searchTerm: ''
})
const [searchTerm, setSearchTerm] = useState<string>('')
useEffect(() => { useEffect(() => {
pluginsSocket?.emit('search', { if(!pluginsSocket){
searchTerm: '', return
offset: offset, }
limit: limit,
sortBy: 'name',
sortDir: 'asc'
})
setOffset(offset+limit)
pluginsSocket!.on('results:search', (data) => { pluginsSocket.on('results:installed', (data:{
setPlugins(data.results) installed: InstalledPlugin[]
})=>{
setInstalledPlugins(data.installed)
}) })
}, []);
pluginsSocket.on('results:updatable', () => {
console.log("Finished install")
})
pluginsSocket.on('finished:install', () => {
pluginsSocket!.emit('getInstalled');
})
pluginsSocket.on('finished:uninstall', () => {
console.log("Finished uninstall")
})
// Reload on reconnect
pluginsSocket.on('connect', ()=>{
// Initial retrieval of installed plugins
pluginsSocket.emit('getInstalled');
pluginsSocket.emit('search', searchParams)
})
pluginsSocket.emit('getInstalled');
// check for updates every 5mins
const interval = setInterval(() => {
pluginsSocket.emit('checkUpdates');
}, 1000 * 60 * 5);
return ()=>{
clearInterval(interval)
}
}, [pluginsSocket]);
useEffect(() => {
if (!pluginsSocket) {
return
}
pluginsSocket?.emit('search', searchParams)
pluginsSocket!.on('results:search', (data: {
results: PluginDef[]
}) => {
setPlugins(data.results)
})
}, [searchParams, pluginsSocket]);
const uninstallPlugin = (pluginName: string)=>{
pluginsSocket!.emit('uninstall', pluginName);
// Remove plugin
setInstalledPlugins(installedPlugins.filter(i=>i.name !== pluginName))
}
const installPlugin = (pluginName: string)=>{
pluginsSocket!.emit('install', pluginName);
setPlugins(plugins.filter(plugin=>plugin.name !== pluginName))
}
useDebounce(()=>{
setSearchParams({
...searchParams,
offset: 0,
searchTerm: searchTerm
})
}, 500, [searchTerm])
return <div> return <div>
<h1>Home Page</h1> <h1>Home Page</h1>
<h2>Installierte Plugins</h2>
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Description</th> <th>Version</th>
<th>Action</th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody style={{overflow: 'auto'}}>
{plugins.map((plugin, index) => { {installedPlugins.map((plugin, index) => {
return <tr key={index}> return <tr key={index}>
<td>{plugin.name}</td> <td>{plugin.name}</td>
<td>{plugin.description}</td> <td>{plugin.version}</td>
<td>test</td> <td onClick={() => {
}}>
<button disabled={plugin.name == "ep_etherpad-lite"} onClick={() => uninstallPlugin(plugin.name)}>Entfernen</button>
</td>
</tr> </tr>
})} })}
</tbody> </tbody>
</table> </table>
<h2>Verfügbare Plugins</h2>
<input type="text" value={searchTerm} onChange={v=>{
setSearchTerm(v.target.value)
}}/>
<table>
<thead>
<tr>
<th>Name</th>
<th style={{width: '30%'}}>Description</th>
<th>Version</th>
<th>Last updated</th>
<th></th>
</tr>
</thead>
<tbody style={{overflow: 'auto'}}>
{plugins.map((plugin, index) => {
return <tr key={index}>
<td><a href={`https://npmjs.com/${plugin.name}`} target="_blank">{plugin.name}</a></td>
<td>{plugin.description}</td>
<td>{plugin.version}</td>
<td>{plugin.time}</td>
<td>
<button onClick={() => installPlugin(plugin.name)}>Installieren</button>
</td>
</tr>
})}
</tbody>
</table>
</div> </div>
} }

View file

@ -5,3 +5,21 @@ export type PluginDef = {
time: string, time: string,
official: boolean, official: boolean,
} }
export type InstalledPlugin = {
name: string,
path: string,
realPath: string,
version:string,
updatable: boolean
}
export type SearchParams = {
searchTerm: string,
offset: number,
limit: number,
sortBy: 'name'|'version',
sortDir: 'asc'|'desc'
}

View file

@ -15,8 +15,18 @@ export const SettingsPage = ()=>{
if (isJSONClean(settings!)) { if (isJSONClean(settings!)) {
// JSON is clean so emit it to the server // JSON is clean so emit it to the server
settingsSocket!.emit('saveSettings', settings!); settingsSocket!.emit('saveSettings', settings!);
useStore.getState().setToastState({
open: true,
title: "Succesfully saved settings",
success: true
})
} else { } else {
console.log('Invalid JSON'); console.log('Invalid JSON');
useStore.getState().setToastState({
open: true,
title: "Error saving settings",
success: false
})
} }
}}>Einstellungen speichern</button> }}>Einstellungen speichern</button>
<button className="settingsButton" onClick={()=>{ <button className="settingsButton" onClick={()=>{

View file

@ -1,6 +1,14 @@
import {create} from "zustand"; import {create} from "zustand";
import {Socket} from "socket.io-client"; import {Socket} from "socket.io-client";
type ToastState = {
description?:string,
title: string,
open: boolean,
success: boolean
}
type StoreState = { type StoreState = {
settings: string|undefined, settings: string|undefined,
setSettings: (settings: string) => void, setSettings: (settings: string) => void,
@ -9,7 +17,9 @@ type StoreState = {
showLoading: boolean, showLoading: boolean,
setShowLoading: (show: boolean) => void, setShowLoading: (show: boolean) => void,
setPluginsSocket: (socket: Socket) => void setPluginsSocket: (socket: Socket) => void
pluginsSocket: Socket|undefined pluginsSocket: Socket|undefined,
toastState: ToastState,
setToastState: (val: ToastState)=>void
} }
@ -21,5 +31,12 @@ export const useStore = create<StoreState>()((set) => ({
showLoading: false, showLoading: false,
setShowLoading: (show: boolean) => set({showLoading: show}), setShowLoading: (show: boolean) => set({showLoading: show}),
pluginsSocket: undefined, pluginsSocket: undefined,
setPluginsSocket: (socket: Socket) => set({pluginsSocket: socket}) setPluginsSocket: (socket: Socket) => set({pluginsSocket: socket}),
setToastState: (val )=>set({toastState: val}),
toastState: {
open: false,
title: '',
description:'',
success: false
}
})); }));

View file

@ -0,0 +1,29 @@
import {useCallback, useEffect, useRef} from "react";
type Args = any[]
export const useAnimationFrame = <Fn extends (...args: Args)=>void>(
callback: Fn,
wait = 0
): ((...args: Parameters<Fn>)=>void)=>{
const rafId = useRef(0)
const render = useCallback(
(...args: Parameters<Fn>)=>{
cancelAnimationFrame(rafId.current)
const timeStart = performance.now()
const renderFrame = (timeNow: number)=>{
if(timeNow-timeStart<wait){
rafId.current = requestAnimationFrame(renderFrame)
return
}
callback(...args)
}
rafId.current = requestAnimationFrame(renderFrame)
}, [callback, wait]
)
useEffect(()=>cancelAnimationFrame(rafId.current),[])
return render
}

26
admin/src/utils/Toast.tsx Normal file
View file

@ -0,0 +1,26 @@
import * as Toast from '@radix-ui/react-toast'
import {useStore} from "../store/store.ts";
import {useMemo} from "react";
export const ToastDialog = ()=>{
const toastState = useStore(state => state.toastState)
const resultingClass = useMemo(()=> {
return toastState.success?'ToastRootSuccess':'ToastRootFailure'
}, [toastState.success])
console.log()
return <>
<Toast.Root className={"ToastRoot "+resultingClass} open={toastState && toastState.open} onOpenChange={()=>{
useStore.getState().setToastState({
...toastState!,
open: !toastState?.open
})
}}>
<Toast.Title className="ToastTitle">{toastState.title}</Toast.Title>
<Toast.Description asChild>
{toastState.description}
</Toast.Description>
</Toast.Root>
<Toast.Viewport className="ToastViewport"/>
</>
}

View file

@ -0,0 +1,22 @@
import {DependencyList, EffectCallback, useMemo, useRef} from "react";
import {useAnimationFrame} from "./AnimationFrameHook";
const defaultDeps: DependencyList = []
export const useDebounce = (
fn:EffectCallback,
wait = 0,
deps = defaultDeps
):void => {
const isFirstRender = useRef(true)
const render = useAnimationFrame(fn, wait)
useMemo(()=>{
if(isFirstRender.current){
isFirstRender.current = false
return
}
render()
}, deps)
}

View file

@ -1,10 +1,19 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc' import react from '@vitejs/plugin-react-swc'
import svgr from 'vite-plugin-svgr' import svgr from 'vite-plugin-svgr'
import {viteStaticCopy} from "vite-plugin-static-copy";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), svgr()], plugins: [react(), svgr(), viteStaticCopy({
targets: [
{
src: '../src/locales',
dest: ''
}
]
})],
base: '/admin',
server:{ server:{
proxy: { proxy: {
'/socket.io/': { '/socket.io/': {