mirror of
https://github.com/ether/etherpad-lite.git
synced 2025-04-20 15:36:16 -04:00
Feat/admin react (#6211)
* Added vite react admin ui. * Added react i18next. * Added pads manager. * Fixed docker build. * Fixed windows build. * Fixed installOnWindows script. * Install only if path exists.
This commit is contained in:
parent
d34b964cc2
commit
db46ffb63b
112 changed files with 3327 additions and 946 deletions
0
admin/src/App.css
Normal file
0
admin/src/App.css
Normal file
104
admin/src/App.tsx
Normal file
104
admin/src/App.tsx
Normal file
|
@ -0,0 +1,104 @@
|
|||
import {useEffect} from 'react'
|
||||
import './App.css'
|
||||
import {connect} from 'socket.io-client'
|
||||
import {isJSONClean} from './utils/utils.ts'
|
||||
import {NavLink, Outlet, useNavigate} from "react-router-dom";
|
||||
import {useStore} from "./store/store.ts";
|
||||
import {LoadingScreen} from "./utils/LoadingScreen.tsx";
|
||||
import {Trans, useTranslation} from "react-i18next";
|
||||
|
||||
const WS_URL = import.meta.env.DEV? 'http://localhost:9001' : ''
|
||||
export const App = ()=> {
|
||||
const setSettings = useStore(state => state.setSettings);
|
||||
const {t} = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/admin-auth/', {
|
||||
method: 'POST'
|
||||
}).then((value)=>{
|
||||
if(!value.ok){
|
||||
navigate('/login')
|
||||
}
|
||||
}).catch(()=>{
|
||||
navigate('/login')
|
||||
})
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t('admin.page-title')
|
||||
|
||||
useStore.getState().setShowLoading(true);
|
||||
const settingSocket = connect(`${WS_URL}/settings`, {
|
||||
transports: ['websocket'],
|
||||
});
|
||||
|
||||
const pluginsSocket = connect(`${WS_URL}/pluginfw/installer`, {
|
||||
transports: ['websocket'],
|
||||
})
|
||||
|
||||
pluginsSocket.on('connect', () => {
|
||||
useStore.getState().setPluginsSocket(pluginsSocket);
|
||||
});
|
||||
|
||||
|
||||
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".
|
||||
useStore.getState().setShowLoading(true)
|
||||
if (reason === 'io server disconnect') {
|
||||
settingSocket.connect();
|
||||
}
|
||||
});
|
||||
|
||||
settingSocket.on('settings', (settings) => {
|
||||
/* Check whether the settings.json is authorized to be viewed */
|
||||
if (settings.results === 'NOT_ALLOWED') {
|
||||
console.log('Not allowed to view settings.json')
|
||||
return;
|
||||
}
|
||||
|
||||
/* Check to make sure the JSON is clean before proceeding */
|
||||
if (isJSONClean(settings.results)) {
|
||||
setSettings(settings.results);
|
||||
} else {
|
||||
alert('Invalid JSON');
|
||||
}
|
||||
useStore.getState().setShowLoading(false);
|
||||
});
|
||||
|
||||
settingSocket.on('saveprogress', (status)=>{
|
||||
console.log(status)
|
||||
})
|
||||
|
||||
return () => {
|
||||
settingSocket.disconnect();
|
||||
pluginsSocket.disconnect()
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <div id="wrapper">
|
||||
<LoadingScreen/>
|
||||
<div className="menu">
|
||||
<h1>Etherpad</h1>
|
||||
<ul>
|
||||
<li><NavLink to="/plugins"><Trans i18nKey="admin_plugins"/></NavLink></li>
|
||||
<li><NavLink to={"/settings"}><Trans i18nKey="admin_settings"/></NavLink></li>
|
||||
<li> <NavLink to={"/help"}><Trans i18nKey="admin_plugins_info"/></NavLink></li>
|
||||
<li><NavLink to={"/pads"}><Trans i18nKey="ep_admin_pads:ep_adminpads2_manage-pads"/></NavLink></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="innerwrapper">
|
||||
<Outlet/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default App
|
489
admin/src/index.css
Normal file
489
admin/src/index.css
Normal file
|
@ -0,0 +1,489 @@
|
|||
:root {
|
||||
--etherpad-color: #0f775b;
|
||||
}
|
||||
|
||||
|
||||
|
||||
html, body, #root {
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
|
||||
}
|
||||
|
||||
*, *:before, *:after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font: 14px helvetica, sans-serif;
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
div.menu {
|
||||
height: 100%;
|
||||
padding: 15px;
|
||||
width: 220px;
|
||||
border-right: 1px solid #ccc;
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
div.menu ul {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
div.menu li {
|
||||
list-style: none;
|
||||
margin-left: 3px;
|
||||
line-height: 3;
|
||||
border-top: 1px solid #ccc;
|
||||
}
|
||||
|
||||
div.menu li:last-child {
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
|
||||
div.innerwrapper {
|
||||
padding: 15px;
|
||||
padding-left: 265px;
|
||||
}
|
||||
|
||||
div.innerwrapper-err {
|
||||
padding: 15px;
|
||||
padding-left: 265px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#wrapper {
|
||||
background: none repeat scroll 0px 0px #FFFFFF;
|
||||
box-shadow: 0px 1px 10px rgba(0, 0, 0, 0.2);
|
||||
margin: auto;
|
||||
max-width: 1150px;
|
||||
min-height: 100%;/*always display a scrollbar*/
|
||||
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 29px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.separator {
|
||||
margin: 10px 0;
|
||||
height: 1px;
|
||||
background: #aaa;
|
||||
background: -webkit-linear-gradient(left, #fff, #aaa 20%, #aaa 80%, #fff);
|
||||
background: -moz-linear-gradient(left, #fff, #aaa 20%, #aaa 80%, #fff);
|
||||
background: -ms-linear-gradient(left, #fff, #aaa 20%, #aaa 80%, #fff);
|
||||
background: -o-linear-gradient(left, #fff, #aaa 20%, #aaa 80%, #fff);
|
||||
}
|
||||
|
||||
form {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#inner {
|
||||
width: 300px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
input {
|
||||
font-weight: bold;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
|
||||
.sort {
|
||||
cursor: pointer;
|
||||
}
|
||||
.sort:after {
|
||||
content: '▲▼'
|
||||
}
|
||||
.sort.up:after {
|
||||
content:'▲'
|
||||
}
|
||||
.sort.down:after {
|
||||
content:'▼'
|
||||
}
|
||||
|
||||
table {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
border-spacing: 0;
|
||||
width: 100%;
|
||||
margin: 20px 0;
|
||||
position:relative; /* Allows us to position the loading indicator relative to the table */
|
||||
}
|
||||
|
||||
table thead tr {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
td, th {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.template {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#installed-plugins td>div {
|
||||
position: relative;/* Allows us to position the loading indicator relative to this row */
|
||||
display: inline-block; /*make this fill the whole cell*/
|
||||
width:100%;
|
||||
}
|
||||
|
||||
.messages {
|
||||
height: 5em;
|
||||
}
|
||||
.messages * {
|
||||
display: none;
|
||||
text-align: center;
|
||||
}
|
||||
.messages .fetching {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.progress {
|
||||
position: absolute;
|
||||
top: 0; left: 0; bottom:0; right:0;
|
||||
padding: auto;
|
||||
|
||||
background: rgb(255,255,255);
|
||||
display: none;
|
||||
}
|
||||
|
||||
#search-progress.progress {
|
||||
padding-top: 20%;
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
.progress * {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.settings {
|
||||
outline: none;
|
||||
width: 100%;
|
||||
min-height: 80vh;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
#response {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
a:link, a:visited, a:hover, a:focus {
|
||||
color: #333333;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:focus, a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.installed-results a:link,
|
||||
.search-results a:link,
|
||||
.installed-results a:visited,
|
||||
.search-results a:visited,
|
||||
.installed-results a:hover,
|
||||
.search-results a:hover,
|
||||
.installed-results a:focus,
|
||||
.search-results a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.installed-results a:focus,
|
||||
.search-results a:focus,
|
||||
.installed-results a:hover,
|
||||
.search-results a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
div.innerwrapper {
|
||||
padding: 0 15px 15px 15px;
|
||||
}
|
||||
|
||||
div.menu {
|
||||
padding: 1px 15px 0 15px;
|
||||
position: static;
|
||||
height: auto;
|
||||
border-right: none;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
border: none;
|
||||
}
|
||||
|
||||
table, thead, tbody, td, tr {
|
||||
display: block;
|
||||
}
|
||||
|
||||
thead tr {
|
||||
display: none;
|
||||
}
|
||||
|
||||
tr {
|
||||
border: 1px solid #ccc;
|
||||
margin-bottom: 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
td {
|
||||
border: none;
|
||||
border-bottom: 1px solid #eee;
|
||||
position: relative;
|
||||
padding-left: 50%;
|
||||
white-space: normal;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
td.name {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
td:before {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
text-align: left;
|
||||
padding-right: 10px;
|
||||
white-space: nowrap;
|
||||
font-weight: bold;
|
||||
content: attr(data-label);
|
||||
}
|
||||
|
||||
td:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
table input[type="button"] {
|
||||
float: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.settings-button-bar {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.login-background {
|
||||
background-image: url("/fond.jpg");
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
|
||||
.login-textinput {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: #fffacc;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #ccc;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
width: 20%;
|
||||
padding: 20px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.login-inner-box{
|
||||
position: relative;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
color: var(--etherpad-color);
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
padding: 10px;
|
||||
background-color: var(--etherpad-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: white;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
|
||||
.dialog-confirm-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
|
||||
.dialog-confirm-content {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
background-color: white;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 20px;
|
||||
z-index: 101;
|
||||
}
|
||||
|
||||
|
||||
.dialog-content {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 20px;
|
||||
z-index: 101;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
color: var(--etherpad-color);
|
||||
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;
|
||||
}
|
||||
|
||||
.help-block {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 20px
|
||||
}
|
||||
|
||||
.search-field {
|
||||
width: 50%;
|
||||
padding: 5px;
|
||||
}
|
57
admin/src/localization/i18n.ts
Normal file
57
admin/src/localization/i18n.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
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 () {
|
||||
},
|
||||
read: async function (language, namespace, callback) {
|
||||
|
||||
let baseURL = import.meta.env.BASE_URL
|
||||
if(namespace === "translation") {
|
||||
// If default we load the translation file
|
||||
baseURL+=`/locales/${language}.json`
|
||||
} else {
|
||||
// Else we load the former plugin translation file
|
||||
baseURL+=`/${namespace}/${language}.json`
|
||||
}
|
||||
|
||||
const localeJSON = await fetch(baseURL, {
|
||||
cache: "force-cache"
|
||||
})
|
||||
let json;
|
||||
|
||||
try {
|
||||
json = JSON.parse(await localeJSON.text())
|
||||
} catch(e) {
|
||||
callback(new Error("Error loading"), null);
|
||||
}
|
||||
|
||||
|
||||
callback(null, json);
|
||||
},
|
||||
|
||||
save: function () {
|
||||
},
|
||||
|
||||
create: function () {
|
||||
/* save the missing translation */
|
||||
},
|
||||
};
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(LazyImportPlugin)
|
||||
.use(initReactI18next)
|
||||
.init(
|
||||
{
|
||||
ns: ['translation','ep_admin_pads'],
|
||||
fallbackLng: 'en'
|
||||
}
|
||||
)
|
||||
|
||||
export default i18n
|
40
admin/src/main.tsx
Normal file
40
admin/src/main.tsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
import {createBrowserRouter, createRoutesFromElements, Route, RouterProvider} from "react-router-dom";
|
||||
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";
|
||||
import {PadPage} from "./pages/PadPage.tsx";
|
||||
import {ToastDialog} from "./utils/Toast.tsx";
|
||||
|
||||
const router = createBrowserRouter(createRoutesFromElements(
|
||||
<><Route element={<App/>}>
|
||||
<Route index element={<HomePage/>}/>
|
||||
<Route path="/plugins" element={<HomePage/>}/>
|
||||
<Route path="/settings" element={<SettingsPage/>}/>
|
||||
<Route path="/help" element={<HelpPage/>}/>
|
||||
<Route path="/pads" element={<PadPage/>}/>
|
||||
</Route><Route path="/login">
|
||||
<Route index element={<LoginScreen/>}/>
|
||||
</Route></>
|
||||
), {
|
||||
basename: import.meta.env.BASE_URL
|
||||
})
|
||||
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<Toast.Provider>
|
||||
<ToastDialog/>
|
||||
<RouterProvider router={router}/>
|
||||
</Toast.Provider>
|
||||
</I18nextProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
70
admin/src/pages/HelpPage.tsx
Normal file
70
admin/src/pages/HelpPage.tsx
Normal file
|
@ -0,0 +1,70 @@
|
|||
import {Trans} from "react-i18next";
|
||||
import {useStore} from "../store/store.ts";
|
||||
import {useEffect, useState} from "react";
|
||||
import {HelpObj} from "./Plugin.ts";
|
||||
|
||||
export const HelpPage = () => {
|
||||
const settingsSocket = useStore(state=>state.settingsSocket)
|
||||
const [helpData, setHelpData] = useState<HelpObj>();
|
||||
|
||||
useEffect(() => {
|
||||
if(!settingsSocket) return;
|
||||
settingsSocket?.on('reply:help', (data) => {
|
||||
setHelpData(data)
|
||||
});
|
||||
|
||||
settingsSocket?.emit('help');
|
||||
}, [settingsSocket]);
|
||||
|
||||
const renderHooks = (hooks:Record<string, Record<string, string>>) => {
|
||||
return Object.keys(hooks).map((hookName, i) => {
|
||||
return <div key={hookName+i}>
|
||||
<h3>{hookName}</h3>
|
||||
<ul>
|
||||
{Object.keys(hooks[hookName]).map((hook, i) => <li>{hook}
|
||||
<ul key={hookName+hook+i}>
|
||||
{Object.keys(hooks[hookName][hook]).map((subHook, i) => <li key={i}>{subHook}</li>)}
|
||||
</ul>
|
||||
</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
if (!helpData) return <div></div>
|
||||
|
||||
return <div>
|
||||
<h1><Trans i18nKey="admin_plugins_info.version"/></h1>
|
||||
<div className="help-block">
|
||||
<div><Trans i18nKey="admin_plugins_info.version_number"/></div>
|
||||
<div>{helpData?.epVersion}</div>
|
||||
<div><Trans i18nKey="admin_plugins_info.version_latest"/></div>
|
||||
<div>{helpData.latestVersion}</div>
|
||||
<div>Git sha</div>
|
||||
<div>{helpData.gitCommit}</div>
|
||||
</div>
|
||||
<h2><Trans i18nKey="admin_plugins.installed"/></h2>
|
||||
<ul>
|
||||
{helpData.installedPlugins.map((plugin, i) => <li key={i}>{plugin}</li>)}
|
||||
</ul>
|
||||
|
||||
<h2><Trans i18nKey="admin_plugins_info.parts"/></h2>
|
||||
<ul>
|
||||
{helpData.installedParts.map((part, i) => <li key={i}>{part}</li>)}
|
||||
</ul>
|
||||
|
||||
<h2><Trans i18nKey="admin_plugins_info.hooks"/></h2>
|
||||
{
|
||||
renderHooks(helpData.installedServerHooks)
|
||||
}
|
||||
|
||||
<h2>
|
||||
<Trans i18nKey="admin_plugins_info.hooks_client"/>
|
||||
{
|
||||
renderHooks(helpData.installedClientHooks)
|
||||
}
|
||||
</h2>
|
||||
|
||||
</div>
|
||||
}
|
179
admin/src/pages/HomePage.tsx
Normal file
179
admin/src/pages/HomePage.tsx
Normal file
|
@ -0,0 +1,179 @@
|
|||
import {useStore} from "../store/store.ts";
|
||||
import {useEffect, useState} from "react";
|
||||
import {InstalledPlugin, PluginDef, SearchParams} from "./Plugin.ts";
|
||||
import {useDebounce} from "../utils/useDebounce.ts";
|
||||
import {Trans, useTranslation} from "react-i18next";
|
||||
|
||||
|
||||
export const HomePage = () => {
|
||||
const pluginsSocket = useStore(state=>state.pluginsSocket)
|
||||
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>('')
|
||||
const {t} = useTranslation()
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if(!pluginsSocket){
|
||||
return
|
||||
}
|
||||
|
||||
pluginsSocket.on('results:installed', (data:{
|
||||
installed: InstalledPlugin[]
|
||||
})=>{
|
||||
setInstalledPlugins(data.installed)
|
||||
})
|
||||
|
||||
pluginsSocket.on('results:updatable', (data) => {
|
||||
data.updatable.forEach((pluginName: string) => {
|
||||
setInstalledPlugins(installedPlugins.map(plugin => {
|
||||
if (plugin.name === pluginName) {
|
||||
return {
|
||||
...plugin,
|
||||
updatable: true
|
||||
}
|
||||
}
|
||||
return plugin
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
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>
|
||||
<h1><Trans i18nKey="admin_plugins"/></h1>
|
||||
|
||||
<h2><Trans i18nKey="admin_plugins.installed"/></h2>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><Trans i18nKey="admin_plugins.name"/></th>
|
||||
<th><Trans i18nKey="admin_plugins.version"/></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody style={{overflow: 'auto'}}>
|
||||
{installedPlugins.map((plugin, index) => {
|
||||
return <tr key={index}>
|
||||
<td>{plugin.name}</td>
|
||||
<td>{plugin.version}</td>
|
||||
<td>
|
||||
{
|
||||
plugin.updatable ?
|
||||
<button onClick={() => installPlugin(plugin.name)}>Update</button>
|
||||
: <button disabled={plugin.name == "ep_etherpad-lite"}
|
||||
onClick={() => uninstallPlugin(plugin.name)}><Trans
|
||||
i18nKey="admin_plugins.installed_uninstall.value"/></button>
|
||||
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
<h2><Trans i18nKey="admin_plugins.available"/></h2>
|
||||
|
||||
<input className="search-field" placeholder={t('admin_plugins.available_search.placeholder')} type="text" value={searchTerm} onChange={v=>{
|
||||
setSearchTerm(v.target.value)
|
||||
}}/>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><Trans i18nKey="admin_plugins.name"/></th>
|
||||
<th style={{width: '30%'}}><Trans i18nKey="admin_plugins.description"/></th>
|
||||
<th><Trans i18nKey="admin_plugins.version"/></th>
|
||||
<th><Trans i18nKey="admin_plugins.last-update"/></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody style={{overflow: 'auto'}}>
|
||||
{plugins.map((plugin) => {
|
||||
return <tr key={plugin.name}>
|
||||
<td><a rel="noopener noreferrer" 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)}><Trans i18nKey="admin_plugins.available_install.value"/></button>
|
||||
</td>
|
||||
</tr>
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
44
admin/src/pages/LoginScreen.tsx
Normal file
44
admin/src/pages/LoginScreen.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
import {useState} from "react";
|
||||
import {useStore} from "../store/store.ts";
|
||||
import {useNavigate} from "react-router-dom";
|
||||
|
||||
export const LoginScreen = ()=>{
|
||||
const navigate = useNavigate()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
const login = ()=>{
|
||||
fetch('/admin-auth/', {
|
||||
method: 'POST',
|
||||
headers:{
|
||||
Authorization: `Basic ${btoa(`${username}:${password}`)}`
|
||||
}
|
||||
}).then(r=>{
|
||||
if(!r.ok) {
|
||||
useStore.getState().setToastState({
|
||||
open: true,
|
||||
title: "Login failed",
|
||||
success: false
|
||||
})
|
||||
} else {
|
||||
navigate('/')
|
||||
}
|
||||
}).catch(e=>{
|
||||
console.error(e)
|
||||
})
|
||||
}
|
||||
|
||||
return <div className="login-background">
|
||||
<div className="login-box">
|
||||
<h1 className="login-title">Login Etherpad</h1>
|
||||
<div className="login-inner-box">
|
||||
<div>Username</div>
|
||||
<input className="login-textinput" type="text" value={username} onChange={v => setUsername(v.target.value)} placeholder="Username"/>
|
||||
<div>Passwort</div>
|
||||
<input className="login-textinput" type="password" value={password}
|
||||
onChange={v => setPassword(v.target.value)} placeholder="Password"/>
|
||||
<input type="button" value="Login" onClick={login} className="login-button"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
172
admin/src/pages/PadPage.tsx
Normal file
172
admin/src/pages/PadPage.tsx
Normal file
|
@ -0,0 +1,172 @@
|
|||
import {Trans, useTranslation} from "react-i18next";
|
||||
import {useEffect, useMemo, useState} from "react";
|
||||
import {useStore} from "../store/store.ts";
|
||||
import {PadSearchQuery, PadSearchResult} from "../utils/PadSearch.ts";
|
||||
import {useDebounce} from "../utils/useDebounce.ts";
|
||||
import {determineSorting} from "../utils/sorting.ts";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
|
||||
export const PadPage = ()=>{
|
||||
const settingsSocket = useStore(state=>state.settingsSocket)
|
||||
const [searchParams, setSearchParams] = useState<PadSearchQuery>({
|
||||
offset: 0,
|
||||
limit: 12,
|
||||
pattern: '',
|
||||
sortBy: 'padName',
|
||||
ascending: true
|
||||
})
|
||||
const {t} = useTranslation()
|
||||
const [searchTerm, setSearchTerm] = useState<string>('')
|
||||
const pads = useStore(state=>state.pads)
|
||||
const pages = useMemo(()=>{
|
||||
if(!pads){
|
||||
return [0]
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(pads!.total / searchParams.limit)
|
||||
return Array.from({length: totalPages}, (_, i) => i+1)
|
||||
},[pads, searchParams.limit])
|
||||
const [deleteDialog, setDeleteDialog] = useState<boolean>(false)
|
||||
const [padToDelete, setPadToDelete] = useState<string>('')
|
||||
|
||||
useDebounce(()=>{
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
pattern: searchTerm
|
||||
})
|
||||
|
||||
}, 500, [searchTerm])
|
||||
|
||||
useEffect(() => {
|
||||
if(!settingsSocket){
|
||||
return
|
||||
}
|
||||
|
||||
settingsSocket.emit('padLoad', searchParams)
|
||||
|
||||
}, [settingsSocket, searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if(!settingsSocket){
|
||||
return
|
||||
}
|
||||
|
||||
settingsSocket.on('results:padLoad', (data: PadSearchResult)=>{
|
||||
useStore.getState().setPads(data);
|
||||
})
|
||||
|
||||
|
||||
settingsSocket.on('results:deletePad', (padID: string)=>{
|
||||
const newPads = useStore.getState().pads?.results?.filter((pad)=>{
|
||||
return pad.padName !== padID
|
||||
})
|
||||
useStore.getState().setPads({
|
||||
total: useStore.getState().pads!.total-1,
|
||||
results: newPads
|
||||
})
|
||||
})
|
||||
}, [settingsSocket, pads]);
|
||||
|
||||
const deletePad = (padID: string)=>{
|
||||
settingsSocket?.emit('deletePad', padID)
|
||||
}
|
||||
|
||||
|
||||
|
||||
return <div>
|
||||
<Dialog.Root open={deleteDialog}><Dialog.Portal>
|
||||
<Dialog.Overlay className="dialog-confirm-overlay" />
|
||||
<Dialog.Content className="dialog-confirm-content">
|
||||
<div className="">
|
||||
<div className=""></div>
|
||||
<div className="">
|
||||
{t("ep_admin_pads:ep_adminpads2_confirm", {
|
||||
padID: padToDelete,
|
||||
})}
|
||||
</div>
|
||||
<div className="settings-button-bar">
|
||||
<button onClick={()=>{
|
||||
setDeleteDialog(false)
|
||||
}}>Cancel</button>
|
||||
<button onClick={()=>{
|
||||
deletePad(padToDelete)
|
||||
setDeleteDialog(false)
|
||||
}}>Ok</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
<h1><Trans i18nKey="ep_admin_pads:ep_adminpads2_manage-pads"/></h1>
|
||||
<input type="text" value={searchTerm} onChange={v=>setSearchTerm(v.target.value)}
|
||||
placeholder={t('ep_admin_pads:ep_adminpads2_search-heading')}/>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'padName')} onClick={()=>{
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
sortBy: 'padName',
|
||||
ascending: !searchParams.ascending
|
||||
})
|
||||
}}><Trans i18nKey="ep_admin_pads:ep_adminpads2_padname"/></th>
|
||||
<th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'lastEdited')} onClick={()=>{
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
sortBy: 'lastEdited',
|
||||
ascending: !searchParams.ascending
|
||||
})
|
||||
}}><Trans i18nKey="ep_admin_pads:ep_adminpads2_pad-user-count"/></th>
|
||||
<th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'userCount')} onClick={()=>{
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
sortBy: 'userCount',
|
||||
ascending: !searchParams.ascending
|
||||
})
|
||||
}}><Trans i18nKey="ep_admin_pads:ep_adminpads2_last-edited"/></th>
|
||||
<th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'revisionNumber')} onClick={()=>{
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
sortBy: 'revisionNumber',
|
||||
ascending: !searchParams.ascending
|
||||
})
|
||||
}}>Revision number</th>
|
||||
<th><Trans i18nKey="ep_admin_pads:ep_adminpads2_action"/></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
pads?.results?.map((pad)=>{
|
||||
return <tr key={pad.padName}>
|
||||
<td style={{textAlign: 'center'}}>{pad.padName}</td>
|
||||
<td style={{textAlign: 'center'}}>{pad.userCount}</td>
|
||||
<td style={{textAlign: 'center'}}>{new Date(pad.lastEdited).toLocaleString()}</td>
|
||||
<td style={{textAlign: 'center'}}>{pad.revisionNumber}</td>
|
||||
<td>
|
||||
<div className="settings-button-bar">
|
||||
<button onClick={()=>{
|
||||
setPadToDelete(pad.padName)
|
||||
setDeleteDialog(true)
|
||||
}}><Trans i18nKey="ep_admin_pads:ep_adminpads2_delete.value"/></button>
|
||||
<button onClick={()=>{
|
||||
window.open(`/p/${pad.padName}`, '_blank')
|
||||
}}>view</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
})
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="settings-button-bar">
|
||||
{pages.map((page)=>{
|
||||
return <button key={page} onClick={()=>{
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
offset: (page-1)*searchParams.limit
|
||||
})
|
||||
}}>{page}</button>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
36
admin/src/pages/Plugin.ts
Normal file
36
admin/src/pages/Plugin.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
export type PluginDef = {
|
||||
name: string,
|
||||
description: string,
|
||||
version: string,
|
||||
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'
|
||||
}
|
||||
|
||||
|
||||
export type HelpObj = {
|
||||
epVersion: string
|
||||
gitCommit: string
|
||||
installedClientHooks: Record<string, Record<string, string>>,
|
||||
installedParts: string[],
|
||||
installedPlugins: string[],
|
||||
installedServerHooks: Record<string, never>,
|
||||
latestVersion: string
|
||||
}
|
45
admin/src/pages/SettingsPage.tsx
Normal file
45
admin/src/pages/SettingsPage.tsx
Normal file
|
@ -0,0 +1,45 @@
|
|||
import {useStore} from "../store/store.ts";
|
||||
import {isJSONClean} from "../utils/utils.ts";
|
||||
import {Trans} from "react-i18next";
|
||||
|
||||
export const SettingsPage = ()=>{
|
||||
const settingsSocket = useStore(state=>state.settingsSocket)
|
||||
|
||||
const settings = useStore(state=>state.settings)
|
||||
|
||||
return <div>
|
||||
<h1><Trans i18nKey="admin_settings.current"/></h1>
|
||||
<textarea value={settings} className="settings" onChange={v => {
|
||||
useStore.getState().setSettings(v.target.value)
|
||||
}}/>
|
||||
<div className="settings-button-bar">
|
||||
<button className="settingsButton" onClick={() => {
|
||||
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 {
|
||||
useStore.getState().setToastState({
|
||||
open: true,
|
||||
title: "Error saving settings",
|
||||
success: false
|
||||
})
|
||||
}
|
||||
}}><Trans i18nKey="admin_settings.current_save.value"/></button>
|
||||
<button className="settingsButton" onClick={() => {
|
||||
settingsSocket!.emit('restartServer');
|
||||
}}><Trans i18nKey="admin_settings.current_restart.value"/></button>
|
||||
</div>
|
||||
<div className="separator"/>
|
||||
<div className="settings-button-bar">
|
||||
<a rel="noopener noreferrer" target="_blank" href="https://github.com/ether/etherpad-lite/wiki/Example-Production-Settings.JSON"><Trans
|
||||
i18nKey="admin_settings.current_example-prod"/></a>
|
||||
<a rel="noopener noreferrer" target="_blank" href="https://github.com/ether/etherpad-lite/wiki/Example-Development-Settings.JSON"><Trans
|
||||
i18nKey="admin_settings.current_example-devel"/></a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
47
admin/src/store/store.ts
Normal file
47
admin/src/store/store.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import {create} from "zustand";
|
||||
import {Socket} from "socket.io-client";
|
||||
import {PadSearchResult} from "../utils/PadSearch.ts";
|
||||
|
||||
type ToastState = {
|
||||
description?:string,
|
||||
title: string,
|
||||
open: boolean,
|
||||
success: boolean
|
||||
}
|
||||
|
||||
|
||||
type StoreState = {
|
||||
settings: string|undefined,
|
||||
setSettings: (settings: string) => void,
|
||||
settingsSocket: Socket|undefined,
|
||||
setSettingsSocket: (socket: Socket) => void,
|
||||
showLoading: boolean,
|
||||
setShowLoading: (show: boolean) => void,
|
||||
setPluginsSocket: (socket: Socket) => void
|
||||
pluginsSocket: Socket|undefined,
|
||||
toastState: ToastState,
|
||||
setToastState: (val: ToastState)=>void,
|
||||
pads: PadSearchResult|undefined,
|
||||
setPads: (pads: PadSearchResult)=>void
|
||||
}
|
||||
|
||||
|
||||
export const useStore = create<StoreState>()((set) => ({
|
||||
settings: undefined,
|
||||
setSettings: (settings: string) => set({settings}),
|
||||
settingsSocket: undefined,
|
||||
setSettingsSocket: (socket: Socket) => set({settingsSocket: socket}),
|
||||
showLoading: false,
|
||||
setShowLoading: (show: boolean) => set({showLoading: show}),
|
||||
pluginsSocket: undefined,
|
||||
setPluginsSocket: (socket: Socket) => set({pluginsSocket: socket}),
|
||||
setToastState: (val )=>set({toastState: val}),
|
||||
toastState: {
|
||||
open: false,
|
||||
title: '',
|
||||
description:'',
|
||||
success: false
|
||||
},
|
||||
pads: undefined,
|
||||
setPads: (pads)=>set({pads})
|
||||
}));
|
29
admin/src/utils/AnimationFrameHook.ts
Normal file
29
admin/src/utils/AnimationFrameHook.ts
Normal 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
|
||||
}
|
19
admin/src/utils/LoadingScreen.tsx
Normal file
19
admin/src/utils/LoadingScreen.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import {useStore} from "../store/store.ts";
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import ReactComponent from './brand.svg?react';
|
||||
export const LoadingScreen = ()=>{
|
||||
const showLoading = useStore(state => state.showLoading)
|
||||
|
||||
return <Dialog.Root open={showLoading}><Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black bg-opacity-50 z-50 dialog-overlay" />
|
||||
<Dialog.Content className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 dialog-content">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="animate-spin w-16 h-16 border-t-2 border-b-2 border-[--fg-color] rounded-full"></div>
|
||||
<div className="mt-4 text-[--fg-color]">
|
||||
<ReactComponent/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
}
|
20
admin/src/utils/PadSearch.ts
Normal file
20
admin/src/utils/PadSearch.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
export type PadSearchQuery = {
|
||||
pattern: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
ascending: boolean;
|
||||
sortBy: string;
|
||||
}
|
||||
|
||||
|
||||
export type PadSearchResult = {
|
||||
total: number;
|
||||
results?: PadType[]
|
||||
}
|
||||
|
||||
export type PadType = {
|
||||
padName: string;
|
||||
lastEdited: number;
|
||||
userCount: number;
|
||||
revisionNumber: number;
|
||||
}
|
26
admin/src/utils/Toast.tsx
Normal file
26
admin/src/utils/Toast.tsx
Normal 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"/>
|
||||
</>
|
||||
}
|
50
admin/src/utils/brand.svg
Normal file
50
admin/src/utils/brand.svg
Normal file
|
@ -0,0 +1,50 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg fill="#0f775b" width="355px" height="355px" viewBox="0 0 355 355" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>Group 10</title>
|
||||
<defs>
|
||||
<!-- top line -->
|
||||
<rect id="path-4" x="41" y="110" width="142" height="25" rx="12.5" fill="#0f775b">
|
||||
<animate attributeName="width" from="0" to="142" dur="3s" fill="freeze"/>
|
||||
</rect>
|
||||
|
||||
<!-- middle line -->
|
||||
<rect id="path-2" x="42" y="167" width="168" height="27" rx="13.5" fill="#0f775b">
|
||||
<animate attributeName="width" from="0" to="168" dur="5s" fill="freeze"/>
|
||||
</rect>
|
||||
|
||||
<!-- bottom line -->
|
||||
<rect id="path-6" x="41" y="226" width="105" height="25" rx="12.5" fill="#0f775b">
|
||||
<animate attributeName="width" from="0" to="105" dur="2s" fill="freeze"/>
|
||||
</rect>
|
||||
</defs>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" >
|
||||
<g id="Group-5-Copy-2" transform="translate(-415.000000, -351.000000)">
|
||||
<g id="Group-10" transform="translate(415.000000, 351.000000)">
|
||||
<g id="Group-9" transform="translate(0.000000, 15.000000)">
|
||||
<!-- small radio wave -->
|
||||
<path stroke="0f775b" d="M237.612214,138.157654 C234.725783,135.28192 230.051254,135.279644 227.164823,138.157654 C224.278392,141.035663 224.278392,145.698831 227.164823,148.57684 C234.93988,156.329214 239.222735,166.601382 239.222735,177.499403 C239.222735,188.397423 234.93988,198.669591 227.164823,206.424696 C224.278392,209.30043 224.278392,213.965873 227.164823,216.841607 C228.608267,218.280384 230.497251,219 232.388518,219 C234.277503,219 236.16877,218.280384 237.612214,216.841607 C248.18012,206.304532 254,192.334147 254,177.499403 C254,162.665114 248.18012,148.694728 237.612214,138.157654 Z" id="Path-Copy-26" fill-opacity="0.200482" fill="#000000" fill-rule="nonzero" opacity="0.754065225">
|
||||
<animate attributeName="opacity" from="0" to="1" dur="3s" repeatCount="indefinite"/>
|
||||
</path>
|
||||
<!-- large radio wave -->
|
||||
<path stroke="0f775b" d="M267.333026,113.158661 C264.51049,110.280446 259.939438,110.280446 257.116902,113.158661 C254.294366,116.039154 254.294366,120.709078 257.116902,123.586837 C285.703837,152.763042 285.703837,200.237641 257.116902,229.413847 C254.294366,232.292061 254.294366,236.96153 257.116902,239.839744 C258.528393,241.280219 260.375562,242 262.224964,242 C264.074365,242 265.921535,241.279763 267.333026,239.837011 C301.555658,204.912576 301.555658,148.084007 267.333026,113.158661 Z" id="Path-Copy-27" fill-opacity="0.250565" fill="#131514" fill-rule="nonzero" opacity="0.754065225">
|
||||
<animate attributeName="opacity" from="0" to="1" dur="3s" repeatCount="indefinite"/>
|
||||
</path>
|
||||
<!-- top line -->
|
||||
<g stroke="0f775b" id="Rectangle-Copy-56">
|
||||
<use fill="#000000" fill-opacity="0.200482" fill-rule="evenodd" xlink:href="#path-4"></use>
|
||||
</g>
|
||||
<!-- middle line -->
|
||||
<g stroke="0f775b" id="Rectangle-Copy-55">
|
||||
<use fill="black" fill-opacity="1" filter="url(#filter-3)" xlink:href="#path-2"></use>
|
||||
<use fill="#000000" fill-opacity="0.200482" fill-rule="evenodd" xlink:href="#path-2"></use>
|
||||
</g>
|
||||
<!-- bottom line -->
|
||||
<g stroke="0f775b" id="Rectangle-Copy-57">
|
||||
<use fill="black" fill-opacity="1" filter="url(#filter-7)" xlink:href="#path-6"></use>
|
||||
<use fill="#000000" fill-opacity="0.200482" xlink:href="#path-6"></use>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 3.8 KiB |
6
admin/src/utils/sorting.ts
Normal file
6
admin/src/utils/sorting.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export const determineSorting = (sortBy: string, ascending: boolean, currentSymbol: string) => {
|
||||
if (sortBy === currentSymbol) {
|
||||
return ascending ? 'sort up' : 'sort down';
|
||||
}
|
||||
return 'sort none';
|
||||
}
|
22
admin/src/utils/useDebounce.ts
Normal file
22
admin/src/utils/useDebounce.ts
Normal 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)
|
||||
}
|
64
admin/src/utils/utils.ts
Normal file
64
admin/src/utils/utils.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
const minify = (json: string)=>{
|
||||
|
||||
let tokenizer = /"|(\/\*)|(\*\/)|(\/\/)|\n|\r/g,
|
||||
in_string = false,
|
||||
in_multiline_comment = false,
|
||||
in_singleline_comment = false,
|
||||
tmp, tmp2, new_str = [], ns = 0, from = 0, lc, rc
|
||||
;
|
||||
|
||||
tokenizer.lastIndex = 0;
|
||||
|
||||
while (tmp = tokenizer.exec(json)) {
|
||||
lc = RegExp.leftContext;
|
||||
rc = RegExp.rightContext;
|
||||
if (!in_multiline_comment && !in_singleline_comment) {
|
||||
tmp2 = lc.substring(from);
|
||||
if (!in_string) {
|
||||
tmp2 = tmp2.replace(/(\n|\r|\s)*/g,"");
|
||||
}
|
||||
new_str[ns++] = tmp2;
|
||||
}
|
||||
from = tokenizer.lastIndex;
|
||||
|
||||
if (tmp[0] == "\"" && !in_multiline_comment && !in_singleline_comment) {
|
||||
tmp2 = lc.match(/(\\)*$/);
|
||||
if (!in_string || !tmp2 || (tmp2[0].length % 2) == 0) { // start of string with ", or unescaped " character found to end string
|
||||
in_string = !in_string;
|
||||
}
|
||||
from--; // include " character in next catch
|
||||
rc = json.substring(from);
|
||||
}
|
||||
else if (tmp[0] == "/*" && !in_string && !in_multiline_comment && !in_singleline_comment) {
|
||||
in_multiline_comment = true;
|
||||
}
|
||||
else if (tmp[0] == "*/" && !in_string && in_multiline_comment && !in_singleline_comment) {
|
||||
in_multiline_comment = false;
|
||||
}
|
||||
else if (tmp[0] == "//" && !in_string && !in_multiline_comment && !in_singleline_comment) {
|
||||
in_singleline_comment = true;
|
||||
}
|
||||
else if ((tmp[0] == "\n" || tmp[0] == "\r") && !in_string && !in_multiline_comment && in_singleline_comment) {
|
||||
in_singleline_comment = false;
|
||||
}
|
||||
else if (!in_multiline_comment && !in_singleline_comment && !(/\n|\r|\s/.test(tmp[0]))) {
|
||||
new_str[ns++] = tmp[0];
|
||||
}
|
||||
}
|
||||
new_str[ns++] = rc;
|
||||
return new_str.join("");
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export const isJSONClean = (data: string) => {
|
||||
let cleanSettings = minify(data);
|
||||
// this is a bit naive. In theory some key/value might contain the sequences ',]' or ',}'
|
||||
cleanSettings = cleanSettings.replace(',]', ']').replace(',}', '}');
|
||||
try {
|
||||
return typeof JSON.parse(cleanSettings) === 'object';
|
||||
} catch (e) {
|
||||
return false; // the JSON failed to be parsed
|
||||
}
|
||||
};
|
2
admin/src/vite-env.d.ts
vendored
Normal file
2
admin/src/vite-env.d.ts
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-svgr/client" />
|
Loading…
Add table
Add a link
Reference in a new issue