mirror of
https://github.com/ether/etherpad-lite.git
synced 2025-04-22 08:26: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
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>
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue