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

    + - - + + - - {plugins.map((plugin, index) => { + + {installedPlugins.map((plugin, index) => { return - - + + })}
    NameDescriptionActionVersion
    {plugin.name}{plugin.description}test{plugin.version} { + }}> + +
    + +

    Verfügbare Plugins

    + + { + setSearchTerm(v.target.value) + }}/> + + + + + + + + + + + + + {plugins.map((plugin, index) => { + return + + + + + + + })} + +
    NameDescriptionVersionLast updated
    {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