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:
SamTV12345 2024-03-09 23:07:09 +01:00 committed by GitHub
parent d34b964cc2
commit db46ffb63b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
112 changed files with 3327 additions and 946 deletions

View file

@ -24,3 +24,4 @@ Dockerfile
settings.json
src/node_modules
admin/node_modules

View file

@ -54,6 +54,12 @@ jobs:
-
name: Install all dependencies and symlink for ep_etherpad-lite
run: bin/installDeps.sh
- name: Install admin ui
working-directory: admin
run: pnpm install
- name: Build admin ui
working-directory: admin
run: pnpm build
-
name: Run the backend tests
run: pnpm test
@ -105,6 +111,12 @@ jobs:
-
name: Install all dependencies and symlink for ep_etherpad-lite
run: bin/installDeps.sh
- name: Install admin ui
working-directory: admin
run: pnpm install
- name: Build admin ui
working-directory: admin
run: pnpm build
-
name: Install Etherpad plugins
run: >
@ -163,6 +175,12 @@ jobs:
-
name: Install all dependencies and symlink for ep_etherpad-lite
run: bin/installOnWindows.bat
- name: Install admin ui
working-directory: admin
run: pnpm install
- name: Build admin ui
working-directory: admin
run: pnpm build
-
name: Fix up the settings.json
run: |
@ -207,6 +225,12 @@ jobs:
${{ runner.os }}-pnpm-store-
- name: Only install direct dependencies
run: pnpm config set auto-install-peers false
- name: Install admin ui
working-directory: admin
run: pnpm install
- name: Build admin ui
working-directory: admin
run: pnpm build
-
name: Install Etherpad plugins
# The --legacy-peer-deps flag is required to work around a bug in npm

View file

@ -12,7 +12,6 @@ jobs:
name: with plugins
runs-on: ubuntu-latest
# node: [16, 19, 20] >> Disabled node 16 and 18 because they do not work
strategy:
fail-fast: false
matrix:
@ -83,11 +82,11 @@ jobs:
run: "sed -i 's/\"enableAdminUITests\": false/\"enableAdminUITests\": true,\\n\"users\":{\"admin\":{\"password\":\"changeme\",\"is_admin\":true}}/' settings.json"
-
name: increase maxHttpBufferSize
run: "sed -i 's/\"maxHttpBufferSize\": 10000/\"maxHttpBufferSize\": 100000/' settings.json"
run: "sed -i 's/\"maxHttpBufferSize\": 10000/\"maxHttpBufferSize\": 10000000/' settings.json"
-
name: Disable import/export rate limiting
run: |
sed -e '/^ *"importExportRateLimiting":/,/^ *\}/ s/"max":.*/"max": 1000000/' -i settings.json
sed -e '/^ *"importExportRateLimiting":/,/^ *\}/ s/"max":.*/"max": 100000000/' -i settings.json
-
name: Remove standard frontend test files, so only admin tests are run
run: mv src/tests/frontend/specs/* /tmp && mv /tmp/admin*.js src/tests/frontend/specs

3
.gitignore vendored
View file

@ -23,4 +23,5 @@ out/
/src/bin/etherpad-1.deb
/src/bin/node.exe
plugin_packages
pnpm-lock.yaml
pnpm-lock.yaml
/src/templates/admin

View file

@ -4,6 +4,13 @@
#
# Author: muxator
FROM node:alpine as adminBuild
WORKDIR /opt/etherpad-lite
COPY ./admin ./admin
RUN cd ./admin && npm install -g pnpm && pnpm install && pnpm run build --outDir ./dist
FROM node:alpine as build
LABEL maintainer="Etherpad team, https://github.com/ether/etherpad-lite"
@ -99,16 +106,18 @@ COPY --chown=etherpad:etherpad ./pnpm-workspace.yaml ./package.json ./
FROM build as development
COPY --chown=etherpad:etherpad ./src/package.json .npmrc ./src/pnpm-lock.yaml ./src/
COPY --chown=etherpad:etherpad --from=adminBuild /opt/etherpad-lite/admin/dist ./src/templates/admin
RUN bin/installDeps.sh && { [ -z "${ETHERPAD_PLUGINS}" ] || \
pnpm install --workspace-root ${ETHERPAD_PLUGINS}; }
FROM build as production
ENV NODE_ENV=production
ENV ETHERPAD_PRODUCTION=true
COPY --chown=etherpad:etherpad ./src ./src
COPY --chown=etherpad:etherpad --from=adminBuild /opt/etherpad-lite/admin/dist ./src/templates/admin
RUN bin/installDeps.sh && { [ -z "${ETHERPAD_PLUGINS}" ] || \
pnpm install --workspace-root ${ETHERPAD_PLUGINS}; } && \

18
admin/.eslintrc.cjs Normal file
View file

@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

24
admin/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

30
admin/README.md Normal file
View file

@ -0,0 +1,30 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default {
// other rules...
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
}
```
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list

14
admin/index.html Normal file
View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Etherpad Admin Dashboard</title>
<link rel="icon" href="/favicon.ico">
</head>
<body>
<div id="root"></div>
<div id="loading"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

38
admin/package.json Normal file
View file

@ -0,0 +1,38 @@
{
"name": "admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"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"
},
"devDependencies": {
"@types/react": "^18.2.56",
"@types/react-dom": "^18.2.19",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"@vitejs/plugin-react-swc": "^3.5.0",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"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"
}
}

View file

View file

@ -0,0 +1,28 @@
{
"@metadata": {
"authors": [
"Meno25",
"محمد أحمد عبد الفتاح"
]
},
"ep_adminpads2_action": "فعل",
"ep_adminpads2_autoupdate-label": "التحديث التلقائي على تغييرات الوسادة",
"ep_adminpads2_autoupdate.title": "لتمكين أو تعطيل التحديثات التلقائية للاستعلام الحالي.",
"ep_adminpads2_confirm": "هل تريد حقًا حذف الوسادة {{padID}}؟",
"ep_adminpads2_delete.value": "حذف",
"ep_adminpads2_last-edited": "آخر تعديل",
"ep_adminpads2_loading": "جارٍ التحميل...",
"ep_adminpads2_manage-pads": "إدارة الفوط",
"ep_adminpads2_no-results": "لا توجد نتائج.",
"ep_adminpads2_pad-user-count": "عدد المستخدمين الوسادة",
"ep_adminpads2_padname": "بادنام",
"ep_adminpads2_search-box.placeholder": "مصطلح البحث",
"ep_adminpads2_search-button.value": "بحث",
"ep_adminpads2_search-done": "اكتمل البحث",
"ep_adminpads2_search-error-explanation": "واجه الخادم خطأً أثناء البحث عن منصات:",
"ep_adminpads2_search-error-title": "فشل في الحصول على قائمة الوسادة",
"ep_adminpads2_search-heading": "ابحث عن الفوط",
"ep_adminpads2_title": "إدارة الوسادة",
"ep_adminpads2_unknown-error": "خطأ غير معروف",
"ep_adminpads2_unknown-status": "حالة غير معروفة"
}

View file

@ -0,0 +1,23 @@
{
"@metadata": {
"authors": [
"আজিজ",
"আফতাবুজ্জামান"
]
},
"ep_adminpads2_action": "কার্য",
"ep_adminpads2_delete.value": "মুছে ফেলুন",
"ep_adminpads2_last-edited": "সর্বশেষ সম্পাদিত",
"ep_adminpads2_loading": "লোড হচ্ছে...",
"ep_adminpads2_manage-pads": "প্যাড পরিচালনা করুন",
"ep_adminpads2_no-results": "ফলাফল নেই",
"ep_adminpads2_padname": "প্যাডের নাম",
"ep_adminpads2_search-button.value": "অনুসন্ধান",
"ep_adminpads2_search-done": "অনুসন্ধান সম্পূর্ণ",
"ep_adminpads2_search-error-explanation": "প্যাড অনুসন্ধান করার সময় সার্ভার একটি ত্রুটির সম্মুখীন হয়েছে:",
"ep_adminpads2_search-error-title": "প্যাডের তালিকা পেতে ব্যর্থ",
"ep_adminpads2_search-heading": "প্যাড অনুসন্ধান করুন",
"ep_adminpads2_title": "প্যাড প্রশাসন",
"ep_adminpads2_unknown-error": "অজানা ত্রুটি",
"ep_adminpads2_unknown-status": "অজানা অবস্থা"
}

View file

@ -0,0 +1,27 @@
{
"@metadata": {
"authors": [
"Mguix"
]
},
"ep_adminpads2_action": "Acció",
"ep_adminpads2_autoupdate-label": "Actualització automàtica en cas de canvis de pad",
"ep_adminpads2_autoupdate.title": "Activa o desactiva les actualitzacions automàtiques per a la consulta actual.",
"ep_adminpads2_confirm": "Esteu segur que voleu suprimir el pad {{padID}}?",
"ep_adminpads2_delete.value": "Esborrar",
"ep_adminpads2_last-edited": "Darrera modificació",
"ep_adminpads2_loading": "Sestà carregant…",
"ep_adminpads2_manage-pads": "Gestiona els pads",
"ep_adminpads2_no-results": "No hi ha cap resultat",
"ep_adminpads2_pad-user-count": "Nombre d'usuaris de pads",
"ep_adminpads2_padname": "Nom del pad",
"ep_adminpads2_search-box.placeholder": "Terme de cerca",
"ep_adminpads2_search-button.value": "Cercar",
"ep_adminpads2_search-done": "Cerca completa",
"ep_adminpads2_search-error-explanation": "El servidor ha trobat un error mentre buscava pads:",
"ep_adminpads2_search-error-title": "No s'ha pogut obtenir la llista del pad",
"ep_adminpads2_search-heading": "Cerca pads",
"ep_adminpads2_title": "Administració del pad",
"ep_adminpads2_unknown-error": "Error desconegut",
"ep_adminpads2_unknown-status": "Estat desconegut"
}

View file

@ -0,0 +1,27 @@
{
"@metadata": {
"authors": [
"Spotter"
]
},
"ep_adminpads2_action": "Akce",
"ep_adminpads2_autoupdate-label": "Automatická aktualizace změn Padu",
"ep_adminpads2_autoupdate.title": "Povolí nebo zakáže automatické aktualizace pro aktuální dotaz.",
"ep_adminpads2_confirm": "Opravdu chcete odstranit pad {{padID}}?",
"ep_adminpads2_delete.value": "Smazat",
"ep_adminpads2_last-edited": "Naposledy upraveno",
"ep_adminpads2_loading": "Načítání…",
"ep_adminpads2_manage-pads": "Spravovat pady",
"ep_adminpads2_no-results": "Žádné výsledky",
"ep_adminpads2_pad-user-count": "Počet uživatelů padu",
"ep_adminpads2_padname": "Název padu",
"ep_adminpads2_search-box.placeholder": "Hledaný výraz",
"ep_adminpads2_search-button.value": "Hledat",
"ep_adminpads2_search-done": "Hledání dokončeno",
"ep_adminpads2_search-error-explanation": "Při hledání padů došlo k chybě serveru:",
"ep_adminpads2_search-error-title": "Seznam padů se nepodařilo získat",
"ep_adminpads2_search-heading": "Hledat pady",
"ep_adminpads2_title": "Správa Padu",
"ep_adminpads2_unknown-error": "Neznámá chyba",
"ep_adminpads2_unknown-status": "Neznámý stav"
}

View file

@ -0,0 +1,27 @@
{
"@metadata": {
"authors": [
"Robin Owain"
]
},
"ep_adminpads2_action": "Gweithred",
"ep_adminpads2_autoupdate-label": "Diweddaru newidiadau pad yn otomatig",
"ep_adminpads2_autoupdate.title": "Galluogi neu analluogi diweddaru'r ymholiad cyfredol.",
"ep_adminpads2_confirm": "Siwr eich bod am ddileu'r pad {{padID}}?",
"ep_adminpads2_delete.value": "Dileu",
"ep_adminpads2_last-edited": "Golygwyd ddiwethaf",
"ep_adminpads2_loading": "Wrthi'n llwytho...",
"ep_adminpads2_manage-pads": "Rheoli'r padiau",
"ep_adminpads2_no-results": "Dim canlyniad",
"ep_adminpads2_pad-user-count": "Cyfri defnyddiwr pad",
"ep_adminpads2_padname": "Enwpad",
"ep_adminpads2_search-box.placeholder": "Term chwilio",
"ep_adminpads2_search-button.value": "Chwilio",
"ep_adminpads2_search-done": "Wedi gorffen",
"ep_adminpads2_search-error-explanation": "Nam ar y gweinydd wrth chwilio'r padiau:",
"ep_adminpads2_search-error-title": "Methwyd a chael y rhestr pad",
"ep_adminpads2_search-heading": "Chwilio am badiau",
"ep_adminpads2_title": "Gweinyddiaeth y pad",
"ep_adminpads2_unknown-error": "Nam o ryw fath",
"ep_adminpads2_unknown-status": "Statws anhysbys"
}

View file

@ -0,0 +1,14 @@
{
"@metadata": {
"authors": [
"Saederup92"
]
},
"ep_adminpads2_action": "Handling",
"ep_adminpads2_delete.value": "Slet",
"ep_adminpads2_last-edited": "Sidst redigeret",
"ep_adminpads2_loading": "Indlæser...",
"ep_adminpads2_no-results": "Ingen resultater",
"ep_adminpads2_unknown-error": "Ukendt fejl",
"ep_adminpads2_unknown-status": "Ukendt status"
}

View file

@ -0,0 +1,32 @@
{
"@metadata": {
"authors": [
"Brettchenweber",
"Justman10000",
"Lorisobi",
"SamTV",
"Umlaut",
"Zunkelty"
]
},
"ep_adminpads2_action": "Aktion",
"ep_adminpads2_autoupdate-label": "Automatisch bei Pad-Änderungen updaten",
"ep_adminpads2_autoupdate.title": "Aktiviert oder deaktiviert automatische Aktualisierungen für die aktuelle Abfrage.",
"ep_adminpads2_confirm": "Willst du das Pad {{padID}} wirklich löschen?",
"ep_adminpads2_delete.value": "Löschen",
"ep_adminpads2_last-edited": "Zuletzt bearbeitet",
"ep_adminpads2_loading": "Lädt...",
"ep_adminpads2_manage-pads": "Pads verwalten",
"ep_adminpads2_no-results": "Keine Ergebnisse",
"ep_adminpads2_pad-user-count": "Nutzerzahl des Pads",
"ep_adminpads2_padname": "Padname",
"ep_adminpads2_search-box.placeholder": "Suchbegriff",
"ep_adminpads2_search-button.value": "Suche",
"ep_adminpads2_search-done": "Suche vollendet",
"ep_adminpads2_search-error-explanation": "Der Server ist bei der Suche nach Pads auf einen Fehler gestoßen:",
"ep_adminpads2_search-error-title": "Pad-Liste konnte nicht abgerufen werden",
"ep_adminpads2_search-heading": "Nach Pads suchen",
"ep_adminpads2_title": "Pad-Verwaltung",
"ep_adminpads2_unknown-error": "Unbekannter Fehler",
"ep_adminpads2_unknown-status": "Unbekannter Status"
}

View file

@ -0,0 +1,28 @@
{
"@metadata": {
"authors": [
"1917 Ekim Devrimi",
"Mirzali"
]
},
"ep_adminpads2_action": "Hereketi",
"ep_adminpads2_autoupdate-label": "Vurnayışanê pedi otomatik rocane kerê",
"ep_adminpads2_autoupdate.title": "Persê mewcudi rê rocaneyışanê otomatika aktiv ke ya zi dewrê ra vecê",
"ep_adminpads2_confirm": ıma qayılê pedê {{padID}} bıesternê?",
"ep_adminpads2_delete.value": "Bestere",
"ep_adminpads2_last-edited": "Vurnayışo peyên",
"ep_adminpads2_loading": "Bar beno...",
"ep_adminpads2_manage-pads": "Pedan idare kerê",
"ep_adminpads2_no-results": "Netice çıniyo",
"ep_adminpads2_pad-user-count": "Amarê karberanê pedi",
"ep_adminpads2_padname": "Padname",
"ep_adminpads2_search-box.placeholder": "termê cıgêrayış",
"ep_adminpads2_search-button.value": "Cı geyre",
"ep_adminpads2_search-done": "Cıgeyrayışi temam",
"ep_adminpads2_search-error-explanation": "Server cıgeyrayışê pedan de yew xetaya raşt ame",
"ep_adminpads2_search-error-title": "Lista pedi nêgêriye",
"ep_adminpads2_search-heading": "Pedan cıgeyrayış",
"ep_adminpads2_title": "İdarey pedi",
"ep_adminpads2_unknown-error": "Xetaya nêzanıtiye",
"ep_adminpads2_unknown-status": "Weziyeto nêzanaye"
}

View file

@ -0,0 +1,27 @@
{
"@metadata": {
"authors": [
"Michawiki"
]
},
"ep_adminpads2_action": "Akcija",
"ep_adminpads2_autoupdate-label": "Pśi změnach na zapisniku awtomatiski aktualizěrowaś",
"ep_adminpads2_autoupdate.title": "Zmóžnja abo znjemóžnja awtomatiske aktualizacije za aktualne wótpšašowanje.",
"ep_adminpads2_confirm": "Cośo napšawdu zapisnik {{padID}} lašowaś?",
"ep_adminpads2_delete.value": "Lašowaś",
"ep_adminpads2_last-edited": "Slědna změna",
"ep_adminpads2_loading": "Zacytujo se...",
"ep_adminpads2_manage-pads": "Zapisniki zastojaś",
"ep_adminpads2_no-results": "Žedne wuslědki",
"ep_adminpads2_pad-user-count": "Licba wužywarjow zapisnika",
"ep_adminpads2_padname": "Mě zapisnika",
"ep_adminpads2_search-box.placeholder": "Pytańske zapśimjeśe",
"ep_adminpads2_search-button.value": "Pytaś",
"ep_adminpads2_search-done": "Pytanje dokóńcone",
"ep_adminpads2_search-error-explanation": "Serwer jo starcył na zmólku, mjaztym až jo pytał za zapisnikami:",
"ep_adminpads2_search-error-title": "Lisćina zapisnikow njedajo se wobstaraś",
"ep_adminpads2_search-heading": "Za zapisnikami pytaś",
"ep_adminpads2_title": "Zapisnikowa administracija",
"ep_adminpads2_unknown-error": "Njeznata zmólka",
"ep_adminpads2_unknown-status": "Njeznaty status"
}

View file

@ -0,0 +1,16 @@
{
"@metadata": {
"authors": [
"Norhorn"
]
},
"ep_adminpads2_delete.value": "Διαγραφή",
"ep_adminpads2_last-edited": "Τελευταία απεξεργασία",
"ep_adminpads2_loading": "Φόρτωση…",
"ep_adminpads2_no-results": "Κανένα αποτέλεσμα",
"ep_adminpads2_search-box.placeholder": "Αναζήτηση όρων",
"ep_adminpads2_search-button.value": "Αναζήτηση",
"ep_adminpads2_search-done": "Ολοκλήρωση αναζήτησης",
"ep_adminpads2_unknown-error": "Άγνωστο σφάλμα",
"ep_adminpads2_unknown-status": "Άγνωστη κατάσταση"
}

View file

@ -0,0 +1,22 @@
{
"ep_adminpads2_action": "Action",
"ep_adminpads2_autoupdate-label": "Auto-update on pad changes",
"ep_adminpads2_autoupdate.title": "Enables or disables automatic updates for the current query.",
"ep_adminpads2_confirm": "Do you really want to delete the pad {{padID}}?",
"ep_adminpads2_delete.value": "Delete",
"ep_adminpads2_last-edited": "Last edited",
"ep_adminpads2_loading": "Loading…",
"ep_adminpads2_manage-pads": "Manage pads",
"ep_adminpads2_no-results": "No results",
"ep_adminpads2_pad-user-count": "Pad user count",
"ep_adminpads2_padname": "Padname",
"ep_adminpads2_search-box.placeholder": "Search term",
"ep_adminpads2_search-button.value": "Search",
"ep_adminpads2_search-done": "Search complete",
"ep_adminpads2_search-error-explanation": "The server encountered an error while searching for pads:",
"ep_adminpads2_search-error-title": "Failed to get pad list",
"ep_adminpads2_search-heading": "Search for pads",
"ep_adminpads2_title": "Pad administration",
"ep_adminpads2_unknown-error": "Unknown error",
"ep_adminpads2_unknown-status": "Unknown status"
}

View file

@ -0,0 +1,27 @@
{
"@metadata": {
"authors": [
"Izendegi"
]
},
"ep_adminpads2_action": "Ekintza",
"ep_adminpads2_autoupdate-label": "Automatikoki eguneratu pad-aren aldaketak daudenean",
"ep_adminpads2_autoupdate.title": "Oraingo kontsultarako eguneratze automatikoak gaitu edo desgaitzen du.",
"ep_adminpads2_confirm": "Ziur zaude {{padID}} pad-a ezabatu nahi duzula?",
"ep_adminpads2_delete.value": "Ezabatu",
"ep_adminpads2_last-edited": "Azkenengoz editatua",
"ep_adminpads2_loading": "Kargatzen...",
"ep_adminpads2_manage-pads": "Kudeatu pad-ak",
"ep_adminpads2_no-results": "Emaitzarik ez",
"ep_adminpads2_pad-user-count": "Pad-erabiltzaile kopurua",
"ep_adminpads2_padname": "Pad-izena",
"ep_adminpads2_search-box.placeholder": "Bilaketa testua",
"ep_adminpads2_search-button.value": "Bilatu",
"ep_adminpads2_search-done": "Bilaketa osatu da",
"ep_adminpads2_search-error-explanation": "Zerbitzariak errore bat izan du pad-ak bilatzean:",
"ep_adminpads2_search-error-title": "Pad-zerrenda eskuratzeak huts egin du",
"ep_adminpads2_search-heading": "Bilatu pad-ak",
"ep_adminpads2_title": "Pad-en kudeaketa",
"ep_adminpads2_unknown-error": "Errore ezezaguna",
"ep_adminpads2_unknown-status": "Egoera ezezaguna"
}

View file

@ -0,0 +1,27 @@
{
"@metadata": {
"authors": [
"Ibrahima Malal Sarr"
]
},
"ep_adminpads2_action": "Baɗal",
"ep_adminpads2_autoupdate-label": "Hesɗitin e jaajol tuma baylagol faɗo",
"ep_adminpads2_autoupdate.title": "Hurminat walla daaƴa kesɗitine jaaje wonannde ɗaɓɓitannde wonaande.",
"ep_adminpads2_confirm": "Aɗa yiɗi e jaati momtude faɗo {{padID}}?",
"ep_adminpads2_delete.value": "Momtu",
"ep_adminpads2_last-edited": "Taƴtaa sakket",
"ep_adminpads2_loading": "Nana loowa…",
"ep_adminpads2_manage-pads": "Toppito paɗe",
"ep_adminpads2_no-results": "Alaa njaltudi",
"ep_adminpads2_pad-user-count": "Limoore huutorɓe faɗo",
"ep_adminpads2_padname": "Innde faɗo",
"ep_adminpads2_search-box.placeholder": "Helmere njiilaw",
"ep_adminpads2_search-button.value": "Yiylo",
"ep_adminpads2_search-done": "Njiylaw timmii",
"ep_adminpads2_search-error-explanation": "Sarworde ndee hawrii e juumre tuma nde yiylotoo faɗo:",
"ep_adminpads2_search-error-title": "Horiima heɓde doggol paɗe",
"ep_adminpads2_search-heading": "Yiylo paɗe",
"ep_adminpads2_title": "Yiylorde paɗe",
"ep_adminpads2_unknown-error": "Juumre nde anndaaka",
"ep_adminpads2_unknown-status": "Ngonka ka anndaaka"
}

View file

@ -0,0 +1,27 @@
{
"@metadata": {
"authors": [
"Artnay",
"Kyykaarme",
"MITO",
"Maantietäjä",
"Yupik"
]
},
"ep_adminpads2_action": "Toiminto",
"ep_adminpads2_delete.value": "Poista",
"ep_adminpads2_last-edited": "Viimeksi muokattu",
"ep_adminpads2_loading": "Ladataan...",
"ep_adminpads2_manage-pads": "Hallitse muistioita",
"ep_adminpads2_no-results": "Ei tuloksia",
"ep_adminpads2_pad-user-count": "Pad-käyttäjien määrä",
"ep_adminpads2_padname": "Muistion nimi",
"ep_adminpads2_search-box.placeholder": "Haettava teksti",
"ep_adminpads2_search-button.value": "Etsi",
"ep_adminpads2_search-done": "Haku valmis",
"ep_adminpads2_search-error-explanation": "Palvelimessa tapahtui virhe etsiessään muistioita:",
"ep_adminpads2_search-error-title": "Pad-luettelon hakeminen epäonnistui",
"ep_adminpads2_search-heading": "Etsi sisältöä",
"ep_adminpads2_unknown-error": "Tuntematon virhe",
"ep_adminpads2_unknown-status": "Tuntematon tila"
}

View file

@ -0,0 +1,27 @@
{
"@metadata": {
"authors": [
"Verdy p"
]
},
"ep_adminpads2_action": "Action",
"ep_adminpads2_autoupdate-label": "Mise à jour automatique en cas de changements du bloc-notes",
"ep_adminpads2_autoupdate.title": "Active ou désactive les mises à jour automatiques pour la requête actuelle.",
"ep_adminpads2_confirm": "Voulez-vous vraiment supprimer le bloc-notes {{padID}}?",
"ep_adminpads2_delete.value": "Supprimer",
"ep_adminpads2_last-edited": "Dernière modification",
"ep_adminpads2_loading": "Chargement en cours...",
"ep_adminpads2_manage-pads": "Gérer les bloc-notes",
"ep_adminpads2_no-results": "Aucun résultat",
"ep_adminpads2_pad-user-count": "Nombre dutilisateurs du bloc-notes",
"ep_adminpads2_padname": "Nom du bloc-notes",
"ep_adminpads2_search-box.placeholder": "Terme de recherche",
"ep_adminpads2_search-button.value": "Rechercher",
"ep_adminpads2_search-done": "Recherche terminée",
"ep_adminpads2_search-error-explanation": "Le serveur a rencontré une erreur en cherchant des blocs-notes:",
"ep_adminpads2_search-error-title": "Échec dobtention de la liste de blocs-notes",
"ep_adminpads2_search-heading": "Rechercher des blocs-notes",
"ep_adminpads2_title": "Administration du bloc-notes",
"ep_adminpads2_unknown-error": "Erreur inconnue",
"ep_adminpads2_unknown-status": "État inconnu"
}

View file

@ -0,0 +1,27 @@
{
"@metadata": {
"authors": [
"Ghose"
]
},
"ep_adminpads2_action": "Accións",
"ep_adminpads2_autoupdate-label": "Actualización automática dos cambios",
"ep_adminpads2_autoupdate.title": "Activa ou desactiva as actualizacións automáticas para a consulta actual.",
"ep_adminpads2_confirm": "Tes a certeza de querer eliminar o pad {{padID}}?",
"ep_adminpads2_delete.value": "Eliminar",
"ep_adminpads2_last-edited": "Última edición",
"ep_adminpads2_loading": "Cargando…",
"ep_adminpads2_manage-pads": "Xestionar pads",
"ep_adminpads2_no-results": "Sen resultados",
"ep_adminpads2_pad-user-count": "Usuarias neste pad",
"ep_adminpads2_padname": "Nome do pad",
"ep_adminpads2_search-box.placeholder": "Buscar termo",
"ep_adminpads2_search-button.value": "Buscar",
"ep_adminpads2_search-done": "Busca completa",
"ep_adminpads2_search-error-explanation": "O servidor atopou un fallo cando buscaba pads:",
"ep_adminpads2_search-error-title": "Non se obtivo a lista de pads",
"ep_adminpads2_search-heading": "Buscar pads",
"ep_adminpads2_title": "Administración do pad",
"ep_adminpads2_unknown-error": "Erro descoñecido",
"ep_adminpads2_unknown-status": "Estado descoñecido"
}

View file

@ -0,0 +1,27 @@
{
"@metadata": {
"authors": [
"YaronSh"
]
},
"ep_adminpads2_action": "פעולה",
"ep_adminpads2_autoupdate-label": "לעדכן אוטומטית כשהמחברת נערכת",
"ep_adminpads2_autoupdate.title": "הפעלה או השבתה של עדכונים אוטומטיים לשאילתה הנוכחית.",
"ep_adminpads2_confirm": "למחוק את המחברת {{padID}}?",
"ep_adminpads2_delete.value": "מחיקה",
"ep_adminpads2_last-edited": "עריכה אחרונה",
"ep_adminpads2_loading": "בטעינה…",
"ep_adminpads2_manage-pads": "ניהול מחברות",
"ep_adminpads2_no-results": "אין תוצאות",
"ep_adminpads2_pad-user-count": "ספירת משתמשים במחברת",
"ep_adminpads2_padname": "שם המחברת",
"ep_adminpads2_search-box.placeholder": "הביטוי לחיפוש",
"ep_adminpads2_search-button.value": "חיפוש",
"ep_adminpads2_search-done": "החיפוש הושלם",
"ep_adminpads2_search-error-explanation": "השרת נתקל בשגיאה בעת חיפוש מחברות:",
"ep_adminpads2_search-error-title": "קבלת רשימת המחברות נכשלה",
"ep_adminpads2_search-heading": "חיפוש אחר מחברות",
"ep_adminpads2_title": "ניהול מחברות",
"ep_adminpads2_unknown-error": "שגיאה בלתי־ידועה",
"ep_adminpads2_unknown-status": "מצב לא ידוע"
}

View file

@ -0,0 +1,27 @@
{
"@metadata": {
"authors": [
"Michawiki"
]
},
"ep_adminpads2_action": "Akcija",
"ep_adminpads2_autoupdate-label": "Při změnach na zapisniku awtomatisce aktualizować",
"ep_adminpads2_autoupdate.title": "Zmóžnja abo znjemóžnja awtomatiske aktualizacije za aktualne wotprašowanje.",
"ep_adminpads2_confirm": "Chceće woprawdźe zapisnik {{padID}} zhašeć?",
"ep_adminpads2_delete.value": "Zhašeć",
"ep_adminpads2_last-edited": "Poslednja změna",
"ep_adminpads2_loading": "Začituje so...",
"ep_adminpads2_manage-pads": "Zapisniki rjadować",
"ep_adminpads2_no-results": "Žane wuslědki.",
"ep_adminpads2_pad-user-count": "Ličba wužiwarjow zapisnika",
"ep_adminpads2_padname": "Mjeno zapisnika",
"ep_adminpads2_search-box.placeholder": "Pytanske zapřijeće",
"ep_adminpads2_search-button.value": "Pytać",
"ep_adminpads2_search-done": "Pytanje dokónčene",
"ep_adminpads2_search-error-explanation": "Serwer je na zmylk storčił, mjeztym zo je za zapisnikami pytał:",
"ep_adminpads2_search-error-title": "Lisćina zapisnikow njeda so wobstarać",
"ep_adminpads2_search-heading": "Za zapisnikami pytać",
"ep_adminpads2_title": "Zapisnikowa administracija",
"ep_adminpads2_unknown-error": "Njeznaty zmylk",
"ep_adminpads2_unknown-status": "Njeznaty status"
}

View file

@ -0,0 +1,25 @@
{
"@metadata": {
"authors": []
},
"ep_adminpads2_action": "Művelet",
"ep_adminpads2_autoupdate-label": "Változáskor jegyzetfüzet önműködő frissítése",
"ep_adminpads2_autoupdate.title": "Önműködő frissítése az jelenlegi lekérdezéshez be- vagy kikapcsolása.",
"ep_adminpads2_confirm": "Biztosan törölni szeretné a(z) {{padID}} jegyzetfüzetet?",
"ep_adminpads2_delete.value": "Törlés",
"ep_adminpads2_last-edited": "Utoljára szerkesztve",
"ep_adminpads2_loading": "Betöltés folyamatban…",
"ep_adminpads2_manage-pads": "Jegyzetfüzetek kezelése",
"ep_adminpads2_no-results": "Nincs találat",
"ep_adminpads2_pad-user-count": "Jegyzetfüzet felhasználók száma",
"ep_adminpads2_padname": "Jegyzetfüzet név",
"ep_adminpads2_search-box.placeholder": "Keresési kifejezés",
"ep_adminpads2_search-button.value": "Keresés",
"ep_adminpads2_search-done": "Keresés befejezve",
"ep_adminpads2_search-error-explanation": "A kiszolgáló hibát észlelt a jegyzetfüzetek keresésekor:",
"ep_adminpads2_search-error-title": "Nem sikerült lekérni a jegyzetfüzet listát",
"ep_adminpads2_search-heading": "Jegyzetfüzetek keresése",
"ep_adminpads2_title": "Jegyzetfüzet felügyelete",
"ep_adminpads2_unknown-error": "Ismeretlen hiba",
"ep_adminpads2_unknown-status": "Ismeretlen állapot"
}

View file

@ -0,0 +1,27 @@
{
"@metadata": {
"authors": [
"McDutchie"
]
},
"ep_adminpads2_action": "Action",
"ep_adminpads2_autoupdate-label": "Actualisar automaticamente le pad in caso de cambiamentos",
"ep_adminpads2_autoupdate.title": "Activa o disactiva le actualisationes automatic pro le consulta actual.",
"ep_adminpads2_confirm": "Es tu secur de voler deler le pad {{padID}}?",
"ep_adminpads2_delete.value": "Deler",
"ep_adminpads2_last-edited": "Ultime modification",
"ep_adminpads2_loading": "Cargamento in curso…",
"ep_adminpads2_manage-pads": "Gerer pads",
"ep_adminpads2_no-results": "Nulle resultato",
"ep_adminpads2_pad-user-count": "Numero de usatores del pad",
"ep_adminpads2_padname": "Nomine del pad",
"ep_adminpads2_search-box.placeholder": "Termino de recerca",
"ep_adminpads2_search-button.value": "Cercar",
"ep_adminpads2_search-done": "Recerca terminate",
"ep_adminpads2_search-error-explanation": "Le servitor ha incontrate un error durante le recerca de pads:",
"ep_adminpads2_search-error-title": "Non poteva obtener le lista de pads",
"ep_adminpads2_search-heading": "Cercar pads",
"ep_adminpads2_title": "Administration de pads",
"ep_adminpads2_unknown-error": "Error incognite",
"ep_adminpads2_unknown-status": "Stato incognite"
}

View file

@ -0,0 +1,16 @@
{
"@metadata": {
"authors": [
"Beta16",
"Luca.favorido"
]
},
"ep_adminpads2_action": "Azione",
"ep_adminpads2_delete.value": "Cancella",
"ep_adminpads2_last-edited": "Ultima modifica",
"ep_adminpads2_loading": "Caricamento…",
"ep_adminpads2_no-results": "Nessun risultato",
"ep_adminpads2_search-button.value": "Cerca",
"ep_adminpads2_unknown-error": "Errore sconosciuto",
"ep_adminpads2_unknown-status": "Stato sconosciuto"
}

View file

@ -0,0 +1,13 @@
{
"@metadata": {
"authors": [
"ಮಲ್ನಾಡಾಚ್ ಕೊಂಕ್ಣೊ"
]
},
"ep_adminpads2_action": "ಕ್ರಿಯೆ",
"ep_adminpads2_delete.value": "ಅಳಿಸು",
"ep_adminpads2_loading": "ತುಂಬಿಸಲಾಗುತ್ತಿದೆ…",
"ep_adminpads2_no-results": "ಯಾವ ಫಲಿತಾಂಶಗಳೂ ಇಲ್ಲ",
"ep_adminpads2_search-button.value": "ಹುಡುಕು",
"ep_adminpads2_unknown-error": "ಅಪರಿಚಿತ ದೋಷ"
}

View file

@ -0,0 +1,28 @@
{
"@metadata": {
"authors": [
"Ykhwong",
"그냥기여자"
]
},
"ep_adminpads2_action": "동작",
"ep_adminpads2_autoupdate-label": "패드 변경 시 자동 업데이트",
"ep_adminpads2_autoupdate.title": "현재 쿼리의 자동 업데이트를 활성화하거나 비활성화합니다.",
"ep_adminpads2_confirm": "{{padID}} 패드를 삭제하시겠습니까?",
"ep_adminpads2_delete.value": "삭제",
"ep_adminpads2_last-edited": "최근 편집",
"ep_adminpads2_loading": "불러오는 중...",
"ep_adminpads2_manage-pads": "패드 관리",
"ep_adminpads2_no-results": "결과 없음",
"ep_adminpads2_pad-user-count": "패드 사용자 수",
"ep_adminpads2_padname": "패드 이름",
"ep_adminpads2_search-box.placeholder": "검색어",
"ep_adminpads2_search-button.value": "검색",
"ep_adminpads2_search-done": "검색 완료",
"ep_adminpads2_search-error-explanation": "패드 검색 중 서버에 오류가 발생했습니다:",
"ep_adminpads2_search-error-title": "패드 목록 가져오기 실패",
"ep_adminpads2_search-heading": "패드 검색",
"ep_adminpads2_title": "패드 관리",
"ep_adminpads2_unknown-error": "알 수 없는 오류",
"ep_adminpads2_unknown-status": "알 수 없는 상태"
}

View file

@ -0,0 +1,27 @@
{
"@metadata": {
"authors": [
"Къарачайлы"
]
},
"ep_adminpads2_action": "Этиу",
"ep_adminpads2_autoupdate-label": "Блокнот тюрлендириулеринде автомат халда джангыртыу",
"ep_adminpads2_autoupdate.title": "Баргъан излем ючюн автомат халда джангыртыуланы джандын неда джукълат.",
"ep_adminpads2_confirm": "{{padID}} блокнотну керти да кетерирге излеймисиз?",
"ep_adminpads2_delete.value": "Кетер",
"ep_adminpads2_last-edited": "Ахыр тюзетиу",
"ep_adminpads2_loading": "Джюклениу…",
"ep_adminpads2_manage-pads": "Блокнотланы оноуун эт",
"ep_adminpads2_no-results": "Эсебле джокъдула",
"ep_adminpads2_pad-user-count": "Блокнот хайырланыучуланы саны",
"ep_adminpads2_padname": "Блокнот ат",
"ep_adminpads2_search-box.placeholder": "Терминни изле",
"ep_adminpads2_search-button.value": "Изле",
"ep_adminpads2_search-done": "Излеу тамамланды",
"ep_adminpads2_search-error-explanation": "Сервер, блокнотланы излеген заманда халат табды:",
"ep_adminpads2_search-error-title": "Блокнот тизмеси алынамады",
"ep_adminpads2_search-heading": "Блокнотла ючюн излеу",
"ep_adminpads2_title": "Блокнот башчылыкъ",
"ep_adminpads2_unknown-error": "Билинмеген халат",
"ep_adminpads2_unknown-status": "Билинмеген турум"
}

View file

@ -0,0 +1,16 @@
{
"@metadata": {
"authors": [
"Robby",
"Volvox"
]
},
"ep_adminpads2_confirm": "Wëllt Dir de Pad {{padID}} wierklech läschen?",
"ep_adminpads2_delete.value": "Läschen",
"ep_adminpads2_loading": "Lueden...",
"ep_adminpads2_no-results": "Keng Resultater",
"ep_adminpads2_padname": "Padnumm",
"ep_adminpads2_search-box.placeholder": "Sichbegrëff",
"ep_adminpads2_search-button.value": "Sichen",
"ep_adminpads2_unknown-error": "Onbekannte Feeler"
}

View file

@ -0,0 +1,27 @@
{
"@metadata": {
"authors": [
"Nokeoo"
]
},
"ep_adminpads2_action": "Veiksmas",
"ep_adminpads2_autoupdate-label": "Automatinis bloknoto keitimų naujinimas",
"ep_adminpads2_autoupdate.title": "Įjungia arba išjungia automatinius dabartinės užklausos atnaujinimus.",
"ep_adminpads2_confirm": "Ar tikrai norite ištrinti bloknotą {{padID}}?",
"ep_adminpads2_delete.value": "Ištrinti",
"ep_adminpads2_last-edited": "Paskutinis pakeitimas",
"ep_adminpads2_loading": "Įkeliama…",
"ep_adminpads2_manage-pads": "Tvarkyti bloknotą",
"ep_adminpads2_no-results": "Nėra rezultatų",
"ep_adminpads2_pad-user-count": "Bloknoto naudotojų skaičius",
"ep_adminpads2_padname": "Bloknoto pavadinimas",
"ep_adminpads2_search-box.placeholder": "Paieškos terminas",
"ep_adminpads2_search-button.value": "Paieška",
"ep_adminpads2_search-done": "Paieška baigta",
"ep_adminpads2_search-error-explanation": "Serveris susidūrė su klaida ieškant bloknotų:",
"ep_adminpads2_search-error-title": "Nepavyko gauti bloknotų sąrašo",
"ep_adminpads2_search-heading": "Ieškokite bloknotų",
"ep_adminpads2_title": "Bloknotų administravimas",
"ep_adminpads2_unknown-error": "Nežinoma klaida",
"ep_adminpads2_unknown-status": "Nežinoma būsena"
}

View file

@ -0,0 +1,27 @@
{
"@metadata": {
"authors": [
"Bjankuloski06"
]
},
"ep_adminpads2_action": "Дејство",
"ep_adminpads2_autoupdate-label": "Самоподнова при измени во тетратката",
"ep_adminpads2_autoupdate.title": "Овозможува или оневозможува самоподнова на тековното барање.",
"ep_adminpads2_confirm": "Дали навистина сакате да ја избришете тетратката {{padID}}?",
"ep_adminpads2_delete.value": "Избриши",
"ep_adminpads2_last-edited": "Последно уредување",
"ep_adminpads2_loading": "Вчитувам…",
"ep_adminpads2_manage-pads": "Раководење со тетратки",
"ep_adminpads2_no-results": "Нема исход",
"ep_adminpads2_pad-user-count": "Корисници на тетратката",
"ep_adminpads2_padname": "Назив на тетратката",
"ep_adminpads2_search-box.placeholder": "Пребаран поим",
"ep_adminpads2_search-button.value": "Пребарај",
"ep_adminpads2_search-done": "Пребарувањето заврши",
"ep_adminpads2_search-error-explanation": "Опслужувачот наиде на грешка при пребарувањето на тетратки:",
"ep_adminpads2_search-error-title": "Не можев да го добијам списокот на тетратки",
"ep_adminpads2_search-heading": "Пребарај по тетратките",
"ep_adminpads2_title": "Администрација на тетратки",
"ep_adminpads2_unknown-error": "Непозната грешка",
"ep_adminpads2_unknown-status": "Непозната состојба"
}

View file

@ -0,0 +1,27 @@
{
"@metadata": {
"authors": [
"Andibecker"
]
},
"ep_adminpads2_action": "လုပ်ဆောင်ချက်",
"ep_adminpads2_autoupdate-label": "pad အပြောင်းအလဲများတွင်အလိုအလျောက်အပ်ဒိတ်လုပ်ပါ",
"ep_adminpads2_autoupdate.title": "လက်ရှိမေးမြန်းမှုအတွက်အလိုအလျောက်အပ်ဒိတ်များကိုဖွင့်ပါသို့မဟုတ်ပိတ်ပါ။",
"ep_adminpads2_confirm": "pad {{padID}} ကိုသင်တကယ်ဖျက်ချင်လား။",
"ep_adminpads2_delete.value": "ဖျက်ပါ",
"ep_adminpads2_last-edited": "နောက်ဆုံးတည်းဖြတ်သည်",
"ep_adminpads2_loading": "ဖွင့်နေသည်…",
"ep_adminpads2_manage-pads": "pads များကိုစီမံပါ",
"ep_adminpads2_no-results": "ရလဒ်မရှိပါ",
"ep_adminpads2_pad-user-count": "Pad အသုံးပြုသူအရေအတွက်",
"ep_adminpads2_padname": "Padname",
"ep_adminpads2_search-box.placeholder": "ဝေါဟာရရှာဖွေပါ",
"ep_adminpads2_search-button.value": "ရှာဖွေပါ",
"ep_adminpads2_search-done": "ရှာဖွေမှုပြီးပါပြီ",
"ep_adminpads2_search-error-explanation": "pads များကိုရှာဖွေစဉ်ဆာဗာသည်အမှားတစ်ခုကြုံခဲ့သည်။",
"ep_adminpads2_search-error-title": "pad စာရင်းရယူရန်မအောင်မြင်ပါ",
"ep_adminpads2_search-heading": "pads များကိုရှာဖွေပါ",
"ep_adminpads2_title": "Pad စီမံခန့်ခွဲမှု",
"ep_adminpads2_unknown-error": "အမည်မသိအမှား",
"ep_adminpads2_unknown-status": "အခြေအနေမသိ"
}

View file

@ -0,0 +1,13 @@
{
"@metadata": {
"authors": [
"EdoAug"
]
},
"ep_adminpads2_action": "Handling",
"ep_adminpads2_last-edited": "Sist redigert",
"ep_adminpads2_loading": "Laster …",
"ep_adminpads2_no-results": "Ingen resultater",
"ep_adminpads2_search-button.value": "Søk",
"ep_adminpads2_search-done": "Søk fullført"
}

View file

@ -0,0 +1,29 @@
{
"@metadata": {
"authors": [
"Aranka",
"McDutchie",
"Spinster"
]
},
"ep_adminpads2_action": "Handeling",
"ep_adminpads2_autoupdate-label": "Automatisch bijwerken bij aanpassingen aan de pad",
"ep_adminpads2_autoupdate.title": "Schakelt automatische updates voor de huidige query in of uit.",
"ep_adminpads2_confirm": "Wil je de pad {{padID}} echt verwijderen?",
"ep_adminpads2_delete.value": "Verwijderen",
"ep_adminpads2_last-edited": "Laatst bewerkt",
"ep_adminpads2_loading": "Bezig met laden...",
"ep_adminpads2_manage-pads": "Pads beheren",
"ep_adminpads2_no-results": "Geen resultaten",
"ep_adminpads2_pad-user-count": "Aantal gebruikers van de pad",
"ep_adminpads2_padname": "Naam van de pad",
"ep_adminpads2_search-box.placeholder": "Zoekterm",
"ep_adminpads2_search-button.value": "Zoeken",
"ep_adminpads2_search-done": "Zoekopdracht voltooid",
"ep_adminpads2_search-error-explanation": "De server heeft een fout aangetroffen tijdens het zoeken naar pads:",
"ep_adminpads2_search-error-title": "Kan lijst met pads niet ophalen",
"ep_adminpads2_search-heading": "Pads zoeken",
"ep_adminpads2_title": "Administratie van pad",
"ep_adminpads2_unknown-error": "Onbekende fout",
"ep_adminpads2_unknown-status": "Onbekende status"
}

View file

@ -0,0 +1,21 @@
{
"@metadata": {
"authors": [
"Quentí"
]
},
"ep_adminpads2_action": "Accion",
"ep_adminpads2_delete.value": "Suprimir",
"ep_adminpads2_last-edited": "Darrièra edicion",
"ep_adminpads2_loading": "Cargament…",
"ep_adminpads2_manage-pads": "Gerir los pads",
"ep_adminpads2_no-results": "Pas cap de resultat",
"ep_adminpads2_padname": "Nom del pad",
"ep_adminpads2_search-box.placeholder": "Tèrme de recèrca",
"ep_adminpads2_search-button.value": "Recercar",
"ep_adminpads2_search-done": "Recèrca acabada",
"ep_adminpads2_search-heading": "Cercar de pads",
"ep_adminpads2_title": "Administracion de pad",
"ep_adminpads2_unknown-error": "Error desconeguda",
"ep_adminpads2_unknown-status": "Estat desconegut"
}

View file

@ -0,0 +1,27 @@
{
"@metadata": {
"authors": [
"Borichèt"
]
},
"ep_adminpads2_action": "Assion",
"ep_adminpads2_autoupdate-label": "Agiornament automàtich an sle modìfiche ëd plancia",
"ep_adminpads2_autoupdate.title": "Abilité o disabilité j'agiornament automàtich për l'arcesta atual.",
"ep_adminpads2_confirm": "Veul-lo për da bon dëscancelé la plancia {{padID}}?",
"ep_adminpads2_delete.value": "Dëscancelé",
"ep_adminpads2_last-edited": "Modificà l'ùltima vira",
"ep_adminpads2_loading": "Cariament…",
"ep_adminpads2_manage-pads": "Gestì le plance",
"ep_adminpads2_no-results": "Gnun arzultà",
"ep_adminpads2_pad-user-count": "Conteur ëd plancia dl'utent",
"ep_adminpads2_padname": "Nòm ëd plancia",
"ep_adminpads2_search-box.placeholder": "Tèrmin d'arserca",
"ep_adminpads2_search-button.value": "Arserca",
"ep_adminpads2_search-done": "Arserca completà",
"ep_adminpads2_search-error-explanation": "Ël servent a l'ha rancontrà n'eror an sërcand dle plance:",
"ep_adminpads2_search-error-title": "Falì a oten-e la lista ëd plance",
"ep_adminpads2_search-heading": "Arserca ëd plance",
"ep_adminpads2_title": "Aministrassion ëd plance",
"ep_adminpads2_unknown-error": "Eror nen conossù",
"ep_adminpads2_unknown-status": "Statù nen conossù"
}

View file

@ -0,0 +1,30 @@
{
"@metadata": {
"authors": [
"Duke of Wikipädia",
"Eduardo Addad de Oliveira",
"Eduardoaddad",
"YuriNikolai"
]
},
"ep_adminpads2_action": "Ação",
"ep_adminpads2_autoupdate-label": "Atualizar notas automaticamente",
"ep_adminpads2_autoupdate.title": "Habilita ou desabilita atualizações automáticas para a consulta atual.",
"ep_adminpads2_confirm": "Você realmente deseja excluir a nota {{padID}}?",
"ep_adminpads2_delete.value": "Excluir",
"ep_adminpads2_last-edited": "Última edição",
"ep_adminpads2_loading": "Carregando…",
"ep_adminpads2_manage-pads": "Gerenciar notas",
"ep_adminpads2_no-results": "Sem resultados",
"ep_adminpads2_pad-user-count": "Número de utilizadores na nota",
"ep_adminpads2_padname": "Nome da nota",
"ep_adminpads2_search-box.placeholder": "Termo de pesquisa",
"ep_adminpads2_search-button.value": "Pesquisar",
"ep_adminpads2_search-done": "Busca completa",
"ep_adminpads2_search-error-explanation": "O servidor encontrou um erro enquanto procurava por notas:",
"ep_adminpads2_search-error-title": "Falha ao buscar lista de notas",
"ep_adminpads2_search-heading": "Pesquisar por notas",
"ep_adminpads2_title": "Administração de notas",
"ep_adminpads2_unknown-error": "Erro desconhecido",
"ep_adminpads2_unknown-status": "Status desconhecido"
}

View file

@ -0,0 +1,27 @@
{
"@metadata": {
"authors": [
"Guilha"
]
},
"ep_adminpads2_action": "Ação",
"ep_adminpads2_autoupdate-label": "Atualizar automaticamente as notas",
"ep_adminpads2_autoupdate.title": "Ativa ou desativa atualizações automáticas na consulta atual.",
"ep_adminpads2_confirm": "Tencionas mesmo eliminar a nota {{padID}}?",
"ep_adminpads2_delete.value": "Eliminar",
"ep_adminpads2_last-edited": "Última edição",
"ep_adminpads2_loading": "A carregar...",
"ep_adminpads2_manage-pads": "Gerir notas",
"ep_adminpads2_no-results": "Sem resultados",
"ep_adminpads2_pad-user-count": "Número de utilizadores na nota",
"ep_adminpads2_padname": "Nome da nota",
"ep_adminpads2_search-box.placeholder": "Procurar termo",
"ep_adminpads2_search-button.value": "Procurar",
"ep_adminpads2_search-done": "Procura completa",
"ep_adminpads2_search-error-explanation": "O servidor encontrou um erro enquanto procurava por notas:",
"ep_adminpads2_search-error-title": "Falha ao obter lista de notas",
"ep_adminpads2_search-heading": "Procurar por notas",
"ep_adminpads2_title": "Administração da nota",
"ep_adminpads2_unknown-error": "Erro desconhecido",
"ep_adminpads2_unknown-status": "Estado desconhecido"
}

View file

@ -0,0 +1,10 @@
{
"@metadata": {
"authors": [
"BryanDavis"
]
},
"ep_adminpads2_action": "{{Identical|Action}}",
"ep_adminpads2_delete.value": "{{Identical|Delete}}",
"ep_adminpads2_search-button.value": "{{Identical|Search}}"
}

View file

@ -0,0 +1,31 @@
{
"@metadata": {
"authors": [
"DDPAT",
"Ice bulldog",
"Megakott",
"Okras",
"Pacha Tchernof"
]
},
"ep_adminpads2_action": "Действие",
"ep_adminpads2_autoupdate-label": "Автообновление при изменении документа",
"ep_adminpads2_autoupdate.title": "Включает или отключает автоматические обновления для текущего запроса.",
"ep_adminpads2_confirm": "Вы действительно хотите удалить документ {{padID}}?",
"ep_adminpads2_delete.value": "Удалить",
"ep_adminpads2_last-edited": "Последнее изменение",
"ep_adminpads2_loading": "Загружается…",
"ep_adminpads2_manage-pads": "Управление документами",
"ep_adminpads2_no-results": "Нет результатов",
"ep_adminpads2_pad-user-count": "Количество пользователей документа",
"ep_adminpads2_padname": "Название документа",
"ep_adminpads2_search-box.placeholder": "Искать термин",
"ep_adminpads2_search-button.value": "Найти",
"ep_adminpads2_search-done": "Поиск завершён",
"ep_adminpads2_search-error-explanation": "Сервер обнаружил ошибку при поиске документов:",
"ep_adminpads2_search-error-title": "Не удалось получить список документов",
"ep_adminpads2_search-heading": "Поиск документов",
"ep_adminpads2_title": "Администрирование документов",
"ep_adminpads2_unknown-error": "Неизвестная ошибка",
"ep_adminpads2_unknown-status": "Неизвестный статус"
}

View file

@ -0,0 +1,27 @@
{
"@metadata": {
"authors": [
"Adr mm"
]
},
"ep_adminpads2_action": "Atzione",
"ep_adminpads2_autoupdate-label": "Atualizatzione automàtica de is modìficas de su pad",
"ep_adminpads2_autoupdate.title": "Ativat o disativat is atualizatziones automàticas pro sa chirca atuale.",
"ep_adminpads2_confirm": "Seguru chi boles cantzellare su pad {{padID}}?",
"ep_adminpads2_delete.value": "Cantzella",
"ep_adminpads2_last-edited": "Ùrtima modìfica",
"ep_adminpads2_loading": "Carrighende...",
"ep_adminpads2_manage-pads": "Gesti is pads",
"ep_adminpads2_no-results": "Nissunu resurtadu",
"ep_adminpads2_pad-user-count": "Nùmeru de utentes de pads",
"ep_adminpads2_padname": "Nòmine de su pad",
"ep_adminpads2_search-box.placeholder": "Tèrmine de chirca",
"ep_adminpads2_search-button.value": "Chirca",
"ep_adminpads2_search-done": "Chirca cumpleta",
"ep_adminpads2_search-error-explanation": "Su serbidore at agatadu un'errore chirchende pads:",
"ep_adminpads2_search-error-title": "Impossìbile otènnere sa lista de pads",
"ep_adminpads2_search-heading": "Chirca pads",
"ep_adminpads2_title": "Amministratzione de su pad",
"ep_adminpads2_unknown-error": "Errore disconnotu",
"ep_adminpads2_unknown-status": "Istadu disconnotu"
}

View file

@ -0,0 +1,14 @@
{
"@metadata": {
"authors": [
"F Samaritani"
]
},
"ep_adminpads2_action": "Azioni",
"ep_adminpads2_delete.value": "Canzella",
"ep_adminpads2_loading": "carrigghendi...",
"ep_adminpads2_no-results": "Nisciun risulthaddu",
"ep_adminpads2_search-button.value": "Zercha",
"ep_adminpads2_search-heading": "Zirchà dati",
"ep_adminpads2_unknown-error": "Errori ischunisciddu"
}

View file

@ -0,0 +1,27 @@
{
"@metadata": {
"authors": [
"Yardom78"
]
},
"ep_adminpads2_action": "Akcia",
"ep_adminpads2_autoupdate-label": "Automatická aktualizácia zmien na poznámkovom bloku",
"ep_adminpads2_autoupdate.title": "Zapne alebo vypne automatickú aktualizáciu.",
"ep_adminpads2_confirm": "Skutočne chcete vymazať poznámkový blok {{padID}}?",
"ep_adminpads2_delete.value": "Vymazať",
"ep_adminpads2_last-edited": "Posledná úprava",
"ep_adminpads2_loading": "Načítavanie...",
"ep_adminpads2_manage-pads": "Spravovať poznámkové bloky",
"ep_adminpads2_no-results": "Žiadne výsledky",
"ep_adminpads2_pad-user-count": "Počet používateľov poznámkového bloku",
"ep_adminpads2_padname": "Názov poznámkového bloku",
"ep_adminpads2_search-box.placeholder": "Hľadať výraz",
"ep_adminpads2_search-button.value": "Hľadať",
"ep_adminpads2_search-done": "Hľadanie dokončené",
"ep_adminpads2_search-error-explanation": "Pri hľadaní poznámkového bloku došlo k chybe:",
"ep_adminpads2_search-error-title": "Nepodarilo sa získať zoznam poznámkových blokov",
"ep_adminpads2_search-heading": "Hľadať poznámkový blok",
"ep_adminpads2_title": "Správa poznámkového bloku",
"ep_adminpads2_unknown-error": "Neznáma chyba",
"ep_adminpads2_unknown-status": "Neznámy stav"
}

View file

@ -0,0 +1,20 @@
{
"@metadata": {
"authors": [
"Saraiki"
]
},
"ep_adminpads2_action": "عمل",
"ep_adminpads2_delete.value": "مٹاؤ",
"ep_adminpads2_last-edited": "چھیکڑی تبدیلی",
"ep_adminpads2_loading": "لوڈ تھین٘دا پئے۔۔۔",
"ep_adminpads2_manage-pads": "پیڈ منیج کرو",
"ep_adminpads2_no-results": "کوئی نتیجہ کائنی",
"ep_adminpads2_padname": "پیڈ ناں",
"ep_adminpads2_search-box.placeholder": "ٹرم ڳولو",
"ep_adminpads2_search-button.value": "ڳولو",
"ep_adminpads2_search-done": "ڳولݨ پورا تھیا",
"ep_adminpads2_search-heading": "پیڈاں دی ڳول",
"ep_adminpads2_unknown-error": "نامعلوم غلطی",
"ep_adminpads2_unknown-status": "نامعلوم حالت"
}

View file

@ -0,0 +1,28 @@
{
"@metadata": {
"authors": [
"Eleassar",
"HairyFotr"
]
},
"ep_adminpads2_action": "Dejanje",
"ep_adminpads2_autoupdate-label": "Samodejno posodabljanje ob spremembah blokcev",
"ep_adminpads2_autoupdate.title": "Omogoči ali onemogoči samodejne posodobitve za trenutno poizvedbo.",
"ep_adminpads2_confirm": "Ali res želite izbrisati blokec {{padID}}?",
"ep_adminpads2_delete.value": "Izbriši",
"ep_adminpads2_last-edited": "Zadnje urejanje",
"ep_adminpads2_loading": "Nalaganje ...",
"ep_adminpads2_manage-pads": "Upravljanje blokcev",
"ep_adminpads2_no-results": "Ni zadetkov",
"ep_adminpads2_pad-user-count": "Število urejevalcev blokca",
"ep_adminpads2_padname": "Ime blokca",
"ep_adminpads2_search-box.placeholder": "Iskalni izraz",
"ep_adminpads2_search-button.value": "Išči",
"ep_adminpads2_search-done": "Iskanje končano",
"ep_adminpads2_search-error-explanation": "Strežnik je med iskanjem blokcev naletel na napako:",
"ep_adminpads2_search-error-title": "Ni bilo mogoče pridobiti seznama blokcev",
"ep_adminpads2_search-heading": "Iskanje blokcev",
"ep_adminpads2_title": "Upravljanje blokcev",
"ep_adminpads2_unknown-error": "Neznana napaka",
"ep_adminpads2_unknown-status": "Neznano stanje"
}

View file

@ -0,0 +1,13 @@
{
"@metadata": {
"authors": [
"Yupik"
]
},
"ep_adminpads2_delete.value": "Siho",
"ep_adminpads2_last-edited": "Majemustáá nubástittum",
"ep_adminpads2_search-box.placeholder": "Uuccâmsääni",
"ep_adminpads2_search-button.value": "Uusâ",
"ep_adminpads2_unknown-error": "Tubdâmettum feilâ",
"ep_adminpads2_unknown-status": "Tubdâmettum tile"
}

View file

@ -0,0 +1,16 @@
{
"@metadata": {
"authors": [
"Yupik"
]
},
"ep_adminpads2_delete.value": "Jaukkâd",
"ep_adminpads2_last-edited": "Mââimõssân muttum",
"ep_adminpads2_no-results": "Ij käunnʼjam ni mii",
"ep_adminpads2_padname": "Mošttʼtõspõʹmmai nõmm",
"ep_adminpads2_search-box.placeholder": "Ooccâmsääʹnn",
"ep_adminpads2_search-button.value": "Ooʒʒ",
"ep_adminpads2_search-heading": "Ooʒʒ mošttʼtõspõʹmmjid",
"ep_adminpads2_unknown-error": "Toobdteʹmes vââʹǩǩ",
"ep_adminpads2_unknown-status": "Toobdteʹmes status"
}

View file

@ -0,0 +1,27 @@
{
"@metadata": {
"authors": [
"Besnik b"
]
},
"ep_adminpads2_action": "Veprim",
"ep_adminpads2_autoupdate-label": "Vetëpërditësohu, kur nga ndryshime blloku",
"ep_adminpads2_autoupdate.title": "Aktivizon ose çaktivizon përditësim të automatizuara për kërkesën e tanishme.",
"ep_adminpads2_confirm": "Doni vërtet të fshihet blloku {{padID}}?",
"ep_adminpads2_delete.value": "Fshije",
"ep_adminpads2_last-edited": "Përpunuar së fundi më",
"ep_adminpads2_loading": "Po ngarkohet…",
"ep_adminpads2_manage-pads": "Administroni blloqe",
"ep_adminpads2_no-results": "Ska përfundime",
"ep_adminpads2_pad-user-count": "Numër përdoruesish blloku",
"ep_adminpads2_padname": "Emër blloku",
"ep_adminpads2_search-box.placeholder": "Term kërkimi",
"ep_adminpads2_search-button.value": "Kërko",
"ep_adminpads2_search-done": "Kërkim i plotë",
"ep_adminpads2_search-error-explanation": "Shërbyesi hasi një gabim teksa kërkohej për blloqe:",
"ep_adminpads2_search-error-title": "Su arrit të merrej listë blloqesh",
"ep_adminpads2_search-heading": "Kërkoni për blloqe",
"ep_adminpads2_title": "Administrim blloku",
"ep_adminpads2_unknown-error": "Gabim i panjohur",
"ep_adminpads2_unknown-status": "Gjendje e panjohur"
}

View file

@ -0,0 +1,28 @@
{
"@metadata": {
"authors": [
"Bengtsson96",
"WikiPhoenix"
]
},
"ep_adminpads2_action": "Åtgärd",
"ep_adminpads2_autoupdate-label": "Uppdatera automatiskt när blocket ändras",
"ep_adminpads2_autoupdate.title": "Aktivera eller inaktivera automatiska uppdatering för nuvarande förfrågan.",
"ep_adminpads2_confirm": "Vill du verkligen radera blocket {{padID}}?",
"ep_adminpads2_delete.value": "Radera",
"ep_adminpads2_last-edited": "Senast redigerad",
"ep_adminpads2_loading": "Läser in …",
"ep_adminpads2_manage-pads": "Hantera block",
"ep_adminpads2_no-results": "Inga resultat",
"ep_adminpads2_pad-user-count": "Antal blockanvändare",
"ep_adminpads2_padname": "Blocknamn",
"ep_adminpads2_search-box.placeholder": "Sökord",
"ep_adminpads2_search-button.value": "Sök",
"ep_adminpads2_search-done": "Sökning slutförd",
"ep_adminpads2_search-error-explanation": "Servern stötte på ett fel vid sökning efter block:",
"ep_adminpads2_search-error-title": "Misslyckades att hämta blocklista",
"ep_adminpads2_search-heading": "Sök efter block",
"ep_adminpads2_title": "Blockadministration",
"ep_adminpads2_unknown-error": "Okänt fel",
"ep_adminpads2_unknown-status": "Okänd status"
}

View file

@ -0,0 +1,27 @@
{
"@metadata": {
"authors": [
"Andibecker"
]
},
"ep_adminpads2_action": "Hatua",
"ep_adminpads2_autoupdate-label": "Sasisha kiotomatiki kwenye mabadiliko ya pedi",
"ep_adminpads2_autoupdate.title": "Huwasha au kulemaza sasisho otomatiki kwa hoja ya sasa.",
"ep_adminpads2_confirm": "Je! Kweli unataka kufuta pedi {{padID}}?",
"ep_adminpads2_delete.value": "Futa",
"ep_adminpads2_last-edited": "Ilihaririwa mwisho",
"ep_adminpads2_loading": "Inapakia...",
"ep_adminpads2_manage-pads": "Dhibiti pedi",
"ep_adminpads2_no-results": "Hakuna matokeo",
"ep_adminpads2_pad-user-count": "Hesabu ya mtumiaji wa pedi",
"ep_adminpads2_padname": "Jina la utani",
"ep_adminpads2_search-box.placeholder": "Neno la utaftaji",
"ep_adminpads2_search-button.value": "Tafuta",
"ep_adminpads2_search-done": "Utafutaji umekamilika",
"ep_adminpads2_search-error-explanation": "Seva ilipata hitilafu wakati wa kutafuta pedi:",
"ep_adminpads2_search-error-title": "Imeshindwa kupata orodha ya pedi",
"ep_adminpads2_search-heading": "Tafuta pedi",
"ep_adminpads2_title": "Usimamizi wa pedi",
"ep_adminpads2_unknown-error": "Hitilafu isiyojulikana",
"ep_adminpads2_unknown-status": "Hali isiyojulikana"
}

View file

@ -0,0 +1,27 @@
{
"@metadata": {
"authors": [
"Andibecker"
]
},
"ep_adminpads2_action": "การกระทำ",
"ep_adminpads2_autoupdate-label": "อัปเดตอัตโนมัติเมื่อเปลี่ยนแผ่น",
"ep_adminpads2_autoupdate.title": "เปิดหรือปิดการอัปเดตอัตโนมัติสำหรับคิวรีปัจจุบัน",
"ep_adminpads2_confirm": "คุณต้องการลบแพด {{padID}} จริงหรือไม่",
"ep_adminpads2_delete.value": "ลบ",
"ep_adminpads2_last-edited": "แก้ไขล่าสุด",
"ep_adminpads2_loading": "กำลังโหลด…",
"ep_adminpads2_manage-pads": "จัดการแผ่นรอง",
"ep_adminpads2_no-results": "ไม่มีผลลัพธ์",
"ep_adminpads2_pad-user-count": "จำนวนผู้ใช้แพด",
"ep_adminpads2_padname": "นามแฝง",
"ep_adminpads2_search-box.placeholder": "คำที่ต้องการค้นหา",
"ep_adminpads2_search-button.value": "ค้นหา",
"ep_adminpads2_search-done": "ค้นหาเสร็จสมบูรณ์",
"ep_adminpads2_search-error-explanation": "เซิร์ฟเวอร์พบข้อผิดพลาดขณะค้นหาแผ่นอิเล็กโทรด:",
"ep_adminpads2_search-error-title": "ไม่สามารถรับรายการแผ่นรอง",
"ep_adminpads2_search-heading": "ค้นหาแผ่นรอง",
"ep_adminpads2_title": "การบริหารแผ่น",
"ep_adminpads2_unknown-error": "ข้อผิดพลาดที่ไม่รู้จัก",
"ep_adminpads2_unknown-status": "ไม่ทราบสถานะ"
}

View file

@ -0,0 +1,17 @@
{
"@metadata": {
"authors": [
"Mrkczr"
]
},
"ep_adminpads2_action": "Kilos",
"ep_adminpads2_delete.value": "Burahin",
"ep_adminpads2_last-edited": "Huling binago",
"ep_adminpads2_loading": "Naglo-load...",
"ep_adminpads2_no-results": "Walang mga resulta",
"ep_adminpads2_search-box.placeholder": "Mga katagang hahanapin:",
"ep_adminpads2_search-button.value": "Hanapin",
"ep_adminpads2_search-done": "Natapos na ang paghahanap",
"ep_adminpads2_unknown-error": "Hindi nalalamang kamalian",
"ep_adminpads2_unknown-status": "Hindi alam na katayuan"
}

View file

@ -0,0 +1,28 @@
{
"@metadata": {
"authors": [
"Hedda",
"MuratTheTurkish"
]
},
"ep_adminpads2_action": "Eylem",
"ep_adminpads2_autoupdate-label": "Bloknot değişikliklerinde otomatik güncelleme",
"ep_adminpads2_autoupdate.title": "Mevcut sorgu için otomatik güncellemeleri etkinleştirir veya devre dışı bırakır.",
"ep_adminpads2_confirm": "{{padID}} bloknotunu gerçekten silmek istiyor musunuz?",
"ep_adminpads2_delete.value": "Sil",
"ep_adminpads2_last-edited": "Son düzenleme",
"ep_adminpads2_loading": "Yükleniyor...",
"ep_adminpads2_manage-pads": "Bloknotları yönet",
"ep_adminpads2_no-results": "Sonuç yok",
"ep_adminpads2_pad-user-count": "Bloknot kullanıcı sayısı",
"ep_adminpads2_padname": "Bloknot adı",
"ep_adminpads2_search-box.placeholder": "Terimi ara",
"ep_adminpads2_search-button.value": "Ara",
"ep_adminpads2_search-done": "Arama tamamlandı",
"ep_adminpads2_search-error-explanation": "Sunucu, bloknotları ararken bir hatayla karşılaştı:",
"ep_adminpads2_search-error-title": "Bloknot listesi alınamadı",
"ep_adminpads2_search-heading": "Bloknotları ara",
"ep_adminpads2_title": "Bloknot yönetimi",
"ep_adminpads2_unknown-error": "Bilinmeyen hata",
"ep_adminpads2_unknown-status": "Bilinmeyen durum"
}

View file

@ -0,0 +1,28 @@
{
"@metadata": {
"authors": [
"DDPAT",
"Ice bulldog"
]
},
"ep_adminpads2_action": "Дія",
"ep_adminpads2_autoupdate-label": "Автоматичне оновлення при зміні майданчика",
"ep_adminpads2_autoupdate.title": "Вмикає або вимикає автоматичне оновлення поточного запиту.",
"ep_adminpads2_confirm": "Ви дійсно хочете видалити панель {{padID}}?",
"ep_adminpads2_delete.value": "Видалити",
"ep_adminpads2_last-edited": "Останнє редагування",
"ep_adminpads2_loading": "Завантаження…",
"ep_adminpads2_manage-pads": "Управління майданчиками",
"ep_adminpads2_no-results": "Немає результатів",
"ep_adminpads2_pad-user-count": "Кількість майданчиків користувача",
"ep_adminpads2_padname": "Назва майданчика",
"ep_adminpads2_search-box.placeholder": "Пошуковий термін",
"ep_adminpads2_search-button.value": "Пошук",
"ep_adminpads2_search-done": "Пошук завершено",
"ep_adminpads2_search-error-explanation": "Під час пошуку педів сервер виявив помилку:",
"ep_adminpads2_search-error-title": "Не вдалося отримати список панелей",
"ep_adminpads2_search-heading": "Пошук майданчиків",
"ep_adminpads2_title": "Введення майданчиків",
"ep_adminpads2_unknown-error": "Невідома помилка",
"ep_adminpads2_unknown-status": "Невідомий статус"
}

View file

@ -0,0 +1,29 @@
{
"@metadata": {
"authors": [
"GuoPC",
"Lakejason0",
"沈澄心"
]
},
"ep_adminpads2_action": "操作",
"ep_adminpads2_autoupdate-label": "在记事本更改时自动更新",
"ep_adminpads2_autoupdate.title": "启用或禁用目前查询的自动更新",
"ep_adminpads2_confirm": "您确定要删除记事本 {{padID}}",
"ep_adminpads2_delete.value": "删除",
"ep_adminpads2_last-edited": "上次编辑于",
"ep_adminpads2_loading": "正在加载…",
"ep_adminpads2_manage-pads": "管理记事本",
"ep_adminpads2_no-results": "没有结果",
"ep_adminpads2_pad-user-count": "记事本用户数",
"ep_adminpads2_padname": "记事本名称",
"ep_adminpads2_search-box.placeholder": "搜索关键词",
"ep_adminpads2_search-button.value": "搜索",
"ep_adminpads2_search-done": "搜索完成",
"ep_adminpads2_search-error-explanation": "搜索记事本时服务器发生错误:",
"ep_adminpads2_search-error-title": "获取记事本列表失败",
"ep_adminpads2_search-heading": "搜索记事本",
"ep_adminpads2_title": "记事本管理",
"ep_adminpads2_unknown-error": "未知错误",
"ep_adminpads2_unknown-status": "未知状态"
}

View file

@ -0,0 +1,28 @@
{
"@metadata": {
"authors": [
"HellojoeAoPS",
"Kly"
]
},
"ep_adminpads2_action": "操作",
"ep_adminpads2_autoupdate-label": "在記事本更改時自動更新",
"ep_adminpads2_autoupdate.title": "啟用或停用目前查詢的自動更新。",
"ep_adminpads2_confirm": "您確定要刪除記事本 {{padID}}",
"ep_adminpads2_delete.value": "刪除",
"ep_adminpads2_last-edited": "上一次編輯",
"ep_adminpads2_loading": "載入中…",
"ep_adminpads2_manage-pads": "管理記事本",
"ep_adminpads2_no-results": "沒有結果",
"ep_adminpads2_pad-user-count": "記事本使用者數",
"ep_adminpads2_padname": "記事本名稱",
"ep_adminpads2_search-box.placeholder": "搜尋關鍵字",
"ep_adminpads2_search-button.value": "搜尋",
"ep_adminpads2_search-done": "搜尋完成",
"ep_adminpads2_search-error-explanation": "當搜尋記事本時伺服器發生錯誤:",
"ep_adminpads2_search-error-title": "取得記事本清單失敗",
"ep_adminpads2_search-heading": "搜尋記事本",
"ep_adminpads2_title": "記事本管理",
"ep_adminpads2_unknown-error": "不明錯誤",
"ep_adminpads2_unknown-status": "不明狀態"
}

BIN
admin/public/fond.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

0
admin/src/App.css Normal file
View file

104
admin/src/App.tsx Normal file
View 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
View 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;
}

View 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
View 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>,
)

View 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>
}

View 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>
}

View 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
View 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
View 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
}

View 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
View 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})
}));

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
}

View 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>
}

View 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
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"/>
</>
}

50
admin/src/utils/brand.svg Normal file
View 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

View 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';
}

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)
}

64
admin/src/utils/utils.ts Normal file
View 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
View file

@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-svgr/client" />

25
admin/tsconfig.json Normal file
View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
admin/tsconfig.node.json Normal file
View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

34
admin/vite.config.ts Normal file
View file

@ -0,0 +1,34 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import svgr from 'vite-plugin-svgr'
import {viteStaticCopy} from "vite-plugin-static-copy";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), svgr(), viteStaticCopy({
targets: [
{
src: '../src/locales',
dest: ''
}
]
})],
base: '/admin',
build:{
outDir: '../src/templates/admin'
},
server:{
proxy: {
'/socket.io/*': {
target: 'http://localhost:9001',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
},
'/admin-auth/': {
target: 'http://localhost:9001',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/admin-prox/, '/admin/')
}
}
}
})

View file

@ -41,7 +41,7 @@ try cd "${TMP_FOLDER}"
# setting NODE_ENV=production ensures that dev dependencies are not installed,
# making the windows package smaller
export NODE_ENV=production
export NODE_ENV=development
rm -rf node_modules || true
rm -rf src/node_modules || true
@ -49,6 +49,15 @@ rm -rf src/node_modules || true
#log "do a normal unix install first..."
#$(try cd ./bin/installDeps.sh)
# Install admin frontend
cd admin
try pnpm install
try pnpm run build
cd ..
# Nuke the admin folder as it is not needed anymore :D
rm -rf admin
log "copy the windows settings template..."
try cp settings.json.template settings.json

View file

@ -9,11 +9,17 @@ cmd /C node -e "" || ( echo "Please install node.js ( https://nodejs.org )" && e
echo _
echo Ensure that all dependencies are up to date... If this is the first time you have run Etherpad please be patient.
mkdir node_modules
cd /D node_modules
mklink /D "ep_etherpad-lite" "..\src"
cd /D ..\src
:: Install admin ui only if available
IF EXIST admin (
cd /D .\admin
dir
cmd /C pnpm i || exit /B 1
cmd /C pnpm run build || exit /B 1
cd /D ..
)
cmd /C pnpm i || exit /B 1
cd /D "%~dp0\.."

View file

@ -1,2 +1,2 @@
packages:
- src
- src

View file

@ -5,8 +5,6 @@ require('eslint-config-etherpad/patch/modern-module-resolution');
module.exports = {
ignorePatterns: [
'/static/js/admin/jquery.autosize.js',
'/static/js/admin/minify.json.js',
'/static/js/vendors/browser.js',
'/static/js/vendors/farbtastic.js',
'/static/js/vendors/gritter.js',

View file

@ -92,14 +92,12 @@
{
"name": "adminplugins",
"hooks": {
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/adminplugins",
"socketio": "ep_etherpad-lite/node/hooks/express/adminplugins"
}
},
{
"name": "adminsettings",
"hooks": {
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/adminsettings",
"socketio": "ep_etherpad-lite/node/hooks/express/adminsettings"
}
},

View file

@ -20,6 +20,7 @@
*/
import {MapArrayType} from "../types/MapType";
import {PadType} from "../types/PadType";
const CustomError = require('../utils/customError');
const Pad = require('../db/Pad');
@ -105,7 +106,7 @@ const padList = new class {
* @param {string} [authorId] - Optional author ID of the user that initiated the pad creation (if
* applicable).
*/
exports.getPad = async (id: string, text: string|null, authorId:string = '') => {
exports.getPad = async (id: string, text?: string|null, authorId:string|null = ''):Promise<PadType> => {
// check if this is a valid padId
if (!exports.isValidPadId(id)) {
throw new CustomError(`${id} is not a valid padId`, 'apierror');
@ -148,6 +149,9 @@ exports.listAllPads = async () => {
return {padIDs};
};
// checks if a pad exists
exports.doesPadExist = async (padId: string) => {
const value = await db.get(`pad:${padId}`);

View file

@ -1,7 +1,9 @@
'use strict';
import {ArgsExpressType} from "../../types/ArgsExpressType";
import path from "path";
const settings = require('ep_etherpad-lite/node/utils/Settings');
const eejs = require('../../eejs');
const ADMIN_PATH = path.join(settings.root, 'src', 'templates', 'admin');
/**
* Add the admin navigation link
@ -11,9 +13,19 @@ const eejs = require('../../eejs');
* @return {*}
*/
exports.expressCreateServer = (hookName:string, args: ArgsExpressType, cb:Function): any => {
args.app.get('/admin', (req:any, res:any) => {
if ('/' !== req.path[req.path.length - 1]) return res.redirect('./admin/');
res.send(eejs.require('ep_etherpad-lite/templates/admin/index.html', {req}));
});
args.app.get('/admin/*', (req:any, res:any, next:Function) => {
if (req.path.includes('.')) {
const relativPath = req.path.split('/admin/')[1];
res.sendFile(path.join(ADMIN_PATH, relativPath));
} else {
res.header('Cache-Control', 'private, no-cache, no-store, must-revalidate');
res.header('Expires', '-1');
res.header('Pragma', 'no-cache');
res.sendFile(path.join(ADMIN_PATH, 'index.html'));
}
});
args.app.get('/admin', (req:any, res:any, next:Function) => {
if ('/' !== req.path[req.path.length - 1]) return res.redirect('./admin/');
})
return cb();
};

View file

@ -12,35 +12,7 @@ const installer = require('../../../static/js/pluginfw/installer');
const pluginDefs = require('../../../static/js/pluginfw/plugin_defs');
const plugins = require('../../../static/js/pluginfw/plugins');
const semver = require('semver');
const UpdateCheck = require('../../utils/UpdateCheck');
exports.expressCreateServer = (hookName:string, args: ArgsExpressType, cb:Function) => {
args.app.get('/admin/plugins', (req:any, res:any) => {
res.send(eejs.require('ep_etherpad-lite/templates/admin/plugins.html', {
plugins: pluginDefs.plugins,
req,
errors: [],
}));
});
args.app.get('/admin/plugins/info', (req:any, res:any) => {
const gitCommit = settings.getGitCommit();
const epVersion = settings.getEpVersion();
res.send(eejs.require('ep_etherpad-lite/templates/admin/plugins-info.html', {
gitCommit,
epVersion,
installedPlugins: `<pre>${plugins.formatPlugins().replace(/, /g, '\n')}</pre>`,
installedParts: `<pre>${plugins.formatParts()}</pre>`,
installedServerHooks: `<div>${plugins.formatHooks('hooks', true)}</div>`,
installedClientHooks: `<div>${plugins.formatHooks('client_hooks', true)}</div>`,
latestVersion: UpdateCheck.getLatestVersion(),
req,
}));
});
return cb();
};
exports.socketio = (hookName:string, args:ArgsExpressType, cb:Function) => {
const io = args.io.of('/pluginfw/installer');

View file

@ -1,20 +1,21 @@
'use strict';
import {PadQueryResult, PadSearchQuery} from "../../types/PadSearchQuery";
import {PadType} from "../../types/PadType";
const eejs = require('../../eejs');
const fsp = require('fs').promises;
const hooks = require('../../../static/js/pluginfw/hooks');
const plugins = require('../../../static/js/pluginfw/plugins');
const settings = require('../../utils/Settings');
const UpdateCheck = require('../../utils/UpdateCheck');
const padManager = require('../../db/PadManager');
const api = require('../../db/API');
const queryPadLimit = 12;
exports.expressCreateServer = (hookName:string, {app}:any) => {
app.get('/admin/settings', (req:any, res:any) => {
res.send(eejs.require('ep_etherpad-lite/templates/admin/settings.html', {
req,
settings: '',
errors: [],
}));
});
};
exports.socketio = (hookName:string, {io}:any) => {
io.of('/settings').on('connection', (socket: any ) => {
@ -38,10 +39,160 @@ exports.socketio = (hookName:string, {io}:any) => {
});
socket.on('saveSettings', async (newSettings:string) => {
console.log('Admin request to save settings through a socket on /admin/settings');
await fsp.writeFile(settings.settingsFilename, newSettings);
socket.emit('saveprogress', 'saved');
});
socket.on('help', ()=> {
const gitCommit = settings.getGitCommit();
const epVersion = settings.getEpVersion();
const hooks:Map<string, Map<string,string>> = plugins.getHooks('hooks', false);
const clientHooks:Map<string, Map<string,string>> = plugins.getHooks('client_hooks', false);
function mapToObject(map: Map<string,any>) {
let obj = Object.create(null);
for (let [k,v] of map) {
if(v instanceof Map) {
obj[k] = mapToObject(v);
} else {
obj[k] = v;
}
}
return obj;
}
socket.emit('reply:help', {
gitCommit,
epVersion,
installedPlugins: plugins.getPlugins(),
installedParts: plugins.getParts(),
installedServerHooks: mapToObject(hooks),
installedClientHooks: mapToObject(clientHooks),
latestVersion: UpdateCheck.getLatestVersion(),
})
});
socket.on('padLoad', async (query: PadSearchQuery) => {
const {padIDs} = await padManager.listAllPads();
const data:{
total: number,
results?: PadQueryResult[]
} = {
total: padIDs.length,
};
let result: string[] = padIDs;
let maxResult;
// Filter out matches
if (query.pattern) {
result = result.filter((padName: string) => padName.includes(query.pattern));
}
data.total = result.length;
maxResult = result.length - 1;
if (maxResult < 0) {
maxResult = 0;
}
if (query.offset && query.offset < 0) {
query.offset = 0;
} else if (query.offset > maxResult) {
query.offset = maxResult;
}
if (query.limit && query.limit < 0) {
query.limit = 0;
} else if (query.limit > queryPadLimit) {
query.limit = queryPadLimit;
}
if (query.sortBy === 'padName') {
result = result.sort((a,b)=>{
if(a < b) return query.ascending ? -1 : 1;
if(a > b) return query.ascending ? 1 : -1;
return 0;
}).slice(query.offset, query.offset + query.limit);
data.results = await Promise.all(result.map(async (padName: string) => {
const pad = await padManager.getPad(padName);
const revisionNumber = pad.getHeadRevisionNumber()
const userCount = api.padUsersCount(padName).padUsersCount;
const lastEdited = await pad.getLastEdit();
return {
padName,
lastEdited,
userCount,
revisionNumber
}}));
} else {
const currentWinners: PadQueryResult[] = []
let queryOffsetCounter = 0
for (let res of result) {
const pad = await padManager.getPad(res);
const padType = {
padName: res,
lastEdited: await pad.getLastEdit(),
userCount: api.padUsersCount(res).padUsersCount,
revisionNumber: pad.getHeadRevisionNumber()
};
if (currentWinners.length < query.limit) {
if(queryOffsetCounter < query.offset){
queryOffsetCounter++
continue
}
currentWinners.push({
padName: res,
lastEdited: await pad.getLastEdit(),
userCount: api.padUsersCount(res).padUsersCount,
revisionNumber: pad.getHeadRevisionNumber()
})
} else {
// Kick out worst pad and replace by current pad
let worstPad = currentWinners.sort((a, b) => {
if (a[query.sortBy] < b[query.sortBy]) return query.ascending ? -1 : 1;
if (a[query.sortBy] > b[query.sortBy]) return query.ascending ? 1 : -1;
return 0;
})
if(worstPad[0]&&worstPad[0][query.sortBy] < padType[query.sortBy]){
if(queryOffsetCounter < query.offset){
queryOffsetCounter++
continue
}
currentWinners.splice(currentWinners.indexOf(worstPad[0]), 1)
currentWinners.push({
padName: res,
lastEdited: await pad.getLastEdit(),
userCount: api.padUsersCount(res).padUsersCount,
revisionNumber: pad.getHeadRevisionNumber()
})
}
}
}
data.results = currentWinners;
}
socket.emit('results:padLoad', data);
})
socket.on('deletePad', async (padId: string) => {
const padExists = await padManager.doesPadExists(padId);
if (padExists) {
const pad = await padManager.getPad(padId);
await pad.remove();
socket.emit('results:deletePad', padId);
}
})
socket.on('restartServer', async () => {
console.log('Admin request to restart server through a socket on /admin/settings');
settings.reloadSettings();
@ -51,3 +202,10 @@ exports.socketio = (hookName:string, {io}:any) => {
});
});
};
const searchPad = async (query:PadSearchQuery) => {
}

View file

@ -76,10 +76,12 @@ export const expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Fu
maxHttpBufferSize: settings.socketIo.maxHttpBufferSize,
})
io.on('connection', (socket:any) => {
const handleConnection = (socket:Socket) => {
sockets.add(socket);
socketsEvents.emit('updated');
// https://socket.io/docs/v3/faq/index.html
// @ts-ignore
const session = socket.request.session;
session.connections++;
session.save();
@ -87,15 +89,9 @@ export const expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Fu
sockets.delete(socket);
socketsEvents.emit('updated');
});
});
}
io.use(socketSessionMiddleware(args));
// Temporary workaround so all clients go through middleware and handle connection
io.of('/pluginfw/installer').use(socketSessionMiddleware(args))
io.of('/settings').use(socketSessionMiddleware(args))
io.use((socket:any, next:Function) => {
const renewSession = (socket:any, next:Function) => {
socket.conn.on('packet', (packet:string) => {
// Tell express-session that the session is still active. The session store can use these
// touch events to defer automatic session cleanup, and if express-session is configured with
@ -106,7 +102,24 @@ export const expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Fu
if (socket.request.session != null) socket.request.session.touch();
});
next();
});
}
io.on('connection', handleConnection);
io.use(socketSessionMiddleware(args));
// Temporary workaround so all clients go through middleware and handle connection
io.of('/pluginfw/installer')
.on('connection',handleConnection)
.use(socketSessionMiddleware(args))
.use(renewSession)
io.of('/settings')
.on('connection',handleConnection)
.use(socketSessionMiddleware(args))
.use(renewSession)
io.use(renewSession);
// var socketIOLogger = log4js.getLogger("socket.io");
// Debug logging now has to be set at an environment level, this is stupid.

View file

@ -50,7 +50,7 @@ exports.userCanModify = (padId: string, req: SocketClientRequest) => {
exports.authnFailureDelayMs = 1000;
const checkAccess = async (req:any, res:any, next: Function) => {
const requireAdmin = req.path.toLowerCase().startsWith('/admin');
const requireAdmin = req.path.toLowerCase().startsWith('/admin-auth');
// ///////////////////////////////////////////////////////////////////////////////////////////////
// Step 1: Check the preAuthorize hook for early permit/deny (permit is only allowed for non-admin
@ -126,7 +126,13 @@ const checkAccess = async (req:any, res:any, next: Function) => {
// completed, or maybe different credentials are required), go to the next step.
// ///////////////////////////////////////////////////////////////////////////////////////////////
if (await authorize()) return next();
if (await authorize()) {
if(requireAdmin) {
res.status(200).send('Authorized')
return
}
return next();
}
// ///////////////////////////////////////////////////////////////////////////////////////////////
// Step 3: Authenticate the user. (Or, if already logged in, reauthenticate with different
@ -163,7 +169,7 @@ const checkAccess = async (req:any, res:any, next: Function) => {
if (await aCallFirst0('authnFailure', {req, res})) return;
if (await aCallFirst0('authFailure', {req, res, next})) return;
// No plugin handled the authentication failure. Fall back to basic authentication.
res.header('WWW-Authenticate', 'Basic realm="Protected Area"');
//res.header('WWW-Authenticate', 'Basic realm="Protected Area"');
// Delay the error response for 1s to slow down brute force attacks.
await new Promise((resolve) => setTimeout(resolve, exports.authnFailureDelayMs));
res.status(401).send('Authentication Required');
@ -188,7 +194,13 @@ const checkAccess = async (req:any, res:any, next: Function) => {
// a login page).
// ///////////////////////////////////////////////////////////////////////////////////////////////
if (await authorize()) return next();
const auth = await authorize()
if (auth && !requireAdmin) return next();
if(auth && requireAdmin) {
res.status(200).send('Authorized')
return
}
if (await aCallFirst0('authzFailure', {req, res})) return;
if (await aCallFirst0('authFailure', {req, res, next})) return;
// No plugin handled the authorization failure.

View file

@ -0,0 +1,15 @@
export type PadSearchQuery = {
pattern: string;
offset: number;
limit: number;
ascending: boolean;
sortBy: "padName" | "lastEdited" | "userCount" | "revisionNumber";
}
export type PadQueryResult = {
padName: string,
lastEdited: string,
userCount: number,
revisionNumber: number
}

Some files were not shown because too many files have changed in this diff Show more