diff --git a/admin/package.json b/admin/package.json
index ceb203f84..d74fedc12 100644
--- a/admin/package.json
+++ b/admin/package.json
@@ -11,8 +11,12 @@
},
"dependencies": {
"@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-dom": "^18.2.0",
+ "react-i18next": "^14.1.0",
"react-router-dom": "^6.22.3",
"zustand": "^4.5.2"
},
@@ -28,6 +32,7 @@
"socket.io-client": "^4.7.4",
"typescript": "^5.2.2",
"vite": "^5.1.4",
+ "vite-plugin-static-copy": "^1.0.1",
"vite-plugin-svgr": "^4.2.0"
}
}
diff --git a/admin/src/App.tsx b/admin/src/App.tsx
index 89ab3c389..6ddc872bf 100644
--- a/admin/src/App.tsx
+++ b/admin/src/App.tsx
@@ -5,11 +5,17 @@ import {isJSONClean} from './utils/utils.ts'
import {NavLink, Outlet} from "react-router-dom";
import {useStore} from "./store/store.ts";
import {LoadingScreen} from "./utils/LoadingScreen.tsx";
+import {ToastDialog} from "./utils/Toast.tsx";
+import {useTranslation} from "react-i18next";
+
export const App = ()=> {
const setSettings = useStore(state => state.setSettings);
+ const {t} = useTranslation()
useEffect(() => {
+ document.title = t('admin.page-title')
+
useStore.getState().setShowLoading(true);
const settingSocket = connect('http://localhost:9001/settings', {
transports: ['websocket'],
@@ -26,13 +32,18 @@ export const App = ()=> {
settingSocket.on('connect', () => {
useStore.getState().setSettingsSocket(settingSocket);
+ useStore.getState().setShowLoading(false)
settingSocket.emit('load');
console.log('connected');
});
+
settingSocket.on('disconnect', (reason) => {
// The settingSocket.io client will automatically try to reconnect for all reasons other than "io
// server disconnect".
- if (reason === 'io server disconnect') settingSocket.connect();
+ useStore.getState().setShowLoading(true)
+ if (reason === 'io server disconnect') {
+ settingSocket.connect();
+ }
});
settingSocket.on('settings', (settings) => {
@@ -63,6 +74,7 @@ export const App = ()=> {
return
+
Etherpad
diff --git a/admin/src/index.css b/admin/src/index.css
index c084f1405..75d968fc3 100644
--- a/admin/src/index.css
+++ b/admin/src/index.css
@@ -14,10 +14,6 @@ html, body, #root {
box-sizing: inherit;
}
-body {
- overflow: hidden;
-}
-
body {
margin: 0;
color: #333;
@@ -64,7 +60,7 @@ div.innerwrapper-err {
box-shadow: 0px 1px 10px rgba(0, 0, 0, 0.2);
margin: auto;
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;
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;
+}
diff --git a/admin/src/localization/i18n.ts b/admin/src/localization/i18n.ts
new file mode 100644
index 000000000..c5456d6f2
--- /dev/null
+++ b/admin/src/localization/i18n.ts
@@ -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
\ No newline at end of file
diff --git a/admin/src/main.tsx b/admin/src/main.tsx
index 9a9dc25a0..5be28df46 100644
--- a/admin/src/main.tsx
+++ b/admin/src/main.tsx
@@ -7,6 +7,9 @@ import {HomePage} from "./pages/HomePage.tsx";
import {SettingsPage} from "./pages/SettingsPage.tsx";
import {LoginScreen} from "./pages/LoginScreen.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(
<>}>
@@ -17,12 +20,17 @@ const router = createBrowserRouter(createRoutesFromElements(
}/>
>
-))
+), {
+ basename: import.meta.env.BASE_URL
+})
ReactDOM.createRoot(document.getElementById('root')!).render(
-
-
+
+
+
+
+
,
)
diff --git a/admin/src/pages/HomePage.tsx b/admin/src/pages/HomePage.tsx
index a8de85d77..4430b58fd 100644
--- a/admin/src/pages/HomePage.tsx
+++ b/admin/src/pages/HomePage.tsx
@@ -1,48 +1,161 @@
import {useStore} from "../store/store.ts";
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 = () => {
const pluginsSocket = useStore(state=>state.pluginsSocket)
- const [limit, setLimit] = useState(20)
- const [offset, setOffset] = useState(0)
const [plugins,setPlugins] = useState([])
+ const [installedPlugins, setInstalledPlugins] = useState([])
+ const [searchParams, setSearchParams] = useState({
+ offset: 0,
+ limit: 99999,
+ sortBy: 'name',
+ sortDir: 'asc',
+ searchTerm: ''
+ })
+ const [searchTerm, setSearchTerm] = useState('')
+
useEffect(() => {
- pluginsSocket?.emit('search', {
- searchTerm: '',
- offset: offset,
- limit: limit,
- sortBy: 'name',
- sortDir: 'asc'
- })
- setOffset(offset+limit)
+ if(!pluginsSocket){
+ return
+ }
- pluginsSocket!.on('results:search', (data) => {
- setPlugins(data.results)
+ pluginsSocket.on('results:installed', (data:{
+ 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
Home Page
+
+
Installierte Plugins
+
Name |
- Description |
- Action |
+ Version |
+ |
-
- {plugins.map((plugin, index) => {
+
+ {installedPlugins.map((plugin, index) => {
return
{plugin.name} |
- {plugin.description} |
- test |
+ {plugin.version} |
+ {
+ }}>
+
+ |
})}
+
+
Verfügbare Plugins
+
+
{
+ setSearchTerm(v.target.value)
+ }}/>
+
+
+
+
+ Name |
+ Description |
+ Version |
+ Last updated |
+ |
+
+
+
+ {plugins.map((plugin, index) => {
+ return
+ {plugin.name} |
+ {plugin.description} |
+ {plugin.version} |
+ {plugin.time} |
+
+
+ |
+
+ })}
+
+
}
diff --git a/admin/src/pages/Plugin.ts b/admin/src/pages/Plugin.ts
index 7cdb4a096..d1c490f21 100644
--- a/admin/src/pages/Plugin.ts
+++ b/admin/src/pages/Plugin.ts
@@ -5,3 +5,21 @@ export type PluginDef = {
time: string,
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'
+}
\ No newline at end of file
diff --git a/admin/src/pages/SettingsPage.tsx b/admin/src/pages/SettingsPage.tsx
index 00da85079..8c26dda8f 100644
--- a/admin/src/pages/SettingsPage.tsx
+++ b/admin/src/pages/SettingsPage.tsx
@@ -15,8 +15,18 @@ export const SettingsPage = ()=>{
if (isJSONClean(settings!)) {
// JSON is clean so emit it to the server
settingsSocket!.emit('saveSettings', settings!);
+ useStore.getState().setToastState({
+ open: true,
+ title: "Succesfully saved settings",
+ success: true
+ })
} else {
console.log('Invalid JSON');
+ useStore.getState().setToastState({
+ open: true,
+ title: "Error saving settings",
+ success: false
+ })
}
}}>Einstellungen speichern