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 settings.json
src/node_modules src/node_modules
admin/node_modules

View file

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

View file

@ -12,7 +12,6 @@ jobs:
name: with plugins name: with plugins
runs-on: ubuntu-latest runs-on: ubuntu-latest
# node: [16, 19, 20] >> Disabled node 16 and 18 because they do not work
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@ -83,11 +82,11 @@ jobs:
run: "sed -i 's/\"enableAdminUITests\": false/\"enableAdminUITests\": true,\\n\"users\":{\"admin\":{\"password\":\"changeme\",\"is_admin\":true}}/' settings.json" run: "sed -i 's/\"enableAdminUITests\": false/\"enableAdminUITests\": true,\\n\"users\":{\"admin\":{\"password\":\"changeme\",\"is_admin\":true}}/' settings.json"
- -
name: increase maxHttpBufferSize 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 name: Disable import/export rate limiting
run: | 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 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 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/etherpad-1.deb
/src/bin/node.exe /src/bin/node.exe
plugin_packages plugin_packages
pnpm-lock.yaml pnpm-lock.yaml
/src/templates/admin

View file

@ -4,6 +4,13 @@
# #
# Author: muxator # 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 FROM node:alpine as build
LABEL maintainer="Etherpad team, https://github.com/ether/etherpad-lite" 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 FROM build as development
COPY --chown=etherpad:etherpad ./src/package.json .npmrc ./src/pnpm-lock.yaml ./src/ 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}" ] || \ RUN bin/installDeps.sh && { [ -z "${ETHERPAD_PLUGINS}" ] || \
pnpm install --workspace-root ${ETHERPAD_PLUGINS}; } pnpm install --workspace-root ${ETHERPAD_PLUGINS}; }
FROM build as production FROM build as production
ENV NODE_ENV=production ENV NODE_ENV=production
ENV ETHERPAD_PRODUCTION=true ENV ETHERPAD_PRODUCTION=true
COPY --chown=etherpad:etherpad ./src ./src 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}" ] || \ RUN bin/installDeps.sh && { [ -z "${ETHERPAD_PLUGINS}" ] || \
pnpm install --workspace-root ${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, # setting NODE_ENV=production ensures that dev dependencies are not installed,
# making the windows package smaller # making the windows package smaller
export NODE_ENV=production export NODE_ENV=development
rm -rf node_modules || true rm -rf node_modules || true
rm -rf src/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..." #log "do a normal unix install first..."
#$(try cd ./bin/installDeps.sh) #$(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..." log "copy the windows settings template..."
try cp settings.json.template settings.json 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 _
echo Ensure that all dependencies are up to date... If this is the first time you have run Etherpad please be patient. 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 cmd /C pnpm i || exit /B 1
cd /D "%~dp0\.." cd /D "%~dp0\.."

View file

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

View file

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

View file

@ -92,14 +92,12 @@
{ {
"name": "adminplugins", "name": "adminplugins",
"hooks": { "hooks": {
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/adminplugins",
"socketio": "ep_etherpad-lite/node/hooks/express/adminplugins" "socketio": "ep_etherpad-lite/node/hooks/express/adminplugins"
} }
}, },
{ {
"name": "adminsettings", "name": "adminsettings",
"hooks": { "hooks": {
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/adminsettings",
"socketio": "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 {MapArrayType} from "../types/MapType";
import {PadType} from "../types/PadType";
const CustomError = require('../utils/customError'); const CustomError = require('../utils/customError');
const Pad = require('../db/Pad'); 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 * @param {string} [authorId] - Optional author ID of the user that initiated the pad creation (if
* applicable). * 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 // check if this is a valid padId
if (!exports.isValidPadId(id)) { if (!exports.isValidPadId(id)) {
throw new CustomError(`${id} is not a valid padId`, 'apierror'); throw new CustomError(`${id} is not a valid padId`, 'apierror');
@ -148,6 +149,9 @@ exports.listAllPads = async () => {
return {padIDs}; return {padIDs};
}; };
// checks if a pad exists // checks if a pad exists
exports.doesPadExist = async (padId: string) => { exports.doesPadExist = async (padId: string) => {
const value = await db.get(`pad:${padId}`); const value = await db.get(`pad:${padId}`);

View file

@ -1,7 +1,9 @@
'use strict'; 'use strict';
import {ArgsExpressType} from "../../types/ArgsExpressType"; 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 * Add the admin navigation link
@ -11,9 +13,19 @@ const eejs = require('../../eejs');
* @return {*} * @return {*}
*/ */
exports.expressCreateServer = (hookName:string, args: ArgsExpressType, cb:Function): any => { exports.expressCreateServer = (hookName:string, args: ArgsExpressType, cb:Function): any => {
args.app.get('/admin', (req:any, res:any) => { args.app.get('/admin/*', (req:any, res:any, next:Function) => {
if ('/' !== req.path[req.path.length - 1]) return res.redirect('./admin/'); if (req.path.includes('.')) {
res.send(eejs.require('ep_etherpad-lite/templates/admin/index.html', {req})); 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(); return cb();
}; };

View file

@ -12,35 +12,7 @@ const installer = require('../../../static/js/pluginfw/installer');
const pluginDefs = require('../../../static/js/pluginfw/plugin_defs'); const pluginDefs = require('../../../static/js/pluginfw/plugin_defs');
const plugins = require('../../../static/js/pluginfw/plugins'); const plugins = require('../../../static/js/pluginfw/plugins');
const semver = require('semver'); 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) => { exports.socketio = (hookName:string, args:ArgsExpressType, cb:Function) => {
const io = args.io.of('/pluginfw/installer'); const io = args.io.of('/pluginfw/installer');

View file

@ -1,20 +1,21 @@
'use strict'; 'use strict';
import {PadQueryResult, PadSearchQuery} from "../../types/PadSearchQuery";
import {PadType} from "../../types/PadType";
const eejs = require('../../eejs'); const eejs = require('../../eejs');
const fsp = require('fs').promises; const fsp = require('fs').promises;
const hooks = require('../../../static/js/pluginfw/hooks'); const hooks = require('../../../static/js/pluginfw/hooks');
const plugins = require('../../../static/js/pluginfw/plugins'); const plugins = require('../../../static/js/pluginfw/plugins');
const settings = require('../../utils/Settings'); 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) => { exports.socketio = (hookName:string, {io}:any) => {
io.of('/settings').on('connection', (socket: any ) => { io.of('/settings').on('connection', (socket: any ) => {
@ -38,10 +39,160 @@ exports.socketio = (hookName:string, {io}:any) => {
}); });
socket.on('saveSettings', async (newSettings:string) => { 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); await fsp.writeFile(settings.settingsFilename, newSettings);
socket.emit('saveprogress', 'saved'); 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 () => { socket.on('restartServer', async () => {
console.log('Admin request to restart server through a socket on /admin/settings'); console.log('Admin request to restart server through a socket on /admin/settings');
settings.reloadSettings(); 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, maxHttpBufferSize: settings.socketIo.maxHttpBufferSize,
}) })
io.on('connection', (socket:any) => {
const handleConnection = (socket:Socket) => {
sockets.add(socket); sockets.add(socket);
socketsEvents.emit('updated'); socketsEvents.emit('updated');
// https://socket.io/docs/v3/faq/index.html // https://socket.io/docs/v3/faq/index.html
// @ts-ignore
const session = socket.request.session; const session = socket.request.session;
session.connections++; session.connections++;
session.save(); session.save();
@ -87,15 +89,9 @@ export const expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Fu
sockets.delete(socket); sockets.delete(socket);
socketsEvents.emit('updated'); socketsEvents.emit('updated');
}); });
}); }
io.use(socketSessionMiddleware(args)); const renewSession = (socket:any, next:Function) => {
// 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) => {
socket.conn.on('packet', (packet:string) => { socket.conn.on('packet', (packet:string) => {
// Tell express-session that the session is still active. The session store can use these // 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 // 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(); if (socket.request.session != null) socket.request.session.touch();
}); });
next(); 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"); // var socketIOLogger = log4js.getLogger("socket.io");
// Debug logging now has to be set at an environment level, this is stupid. // 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; exports.authnFailureDelayMs = 1000;
const checkAccess = async (req:any, res:any, next: Function) => { 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 // 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. // 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 // 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('authnFailure', {req, res})) return;
if (await aCallFirst0('authFailure', {req, res, next})) return; if (await aCallFirst0('authFailure', {req, res, next})) return;
// No plugin handled the authentication failure. Fall back to basic authentication. // 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. // Delay the error response for 1s to slow down brute force attacks.
await new Promise((resolve) => setTimeout(resolve, exports.authnFailureDelayMs)); await new Promise((resolve) => setTimeout(resolve, exports.authnFailureDelayMs));
res.status(401).send('Authentication Required'); res.status(401).send('Authentication Required');
@ -188,7 +194,13 @@ const checkAccess = async (req:any, res:any, next: Function) => {
// a login page). // 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('authzFailure', {req, res})) return;
if (await aCallFirst0('authFailure', {req, res, next})) return; if (await aCallFirst0('authFailure', {req, res, next})) return;
// No plugin handled the authorization failure. // 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