diff --git a/.dockerignore b/.dockerignore
index d8d3a3ebe..f7accabfd 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -24,3 +24,4 @@ Dockerfile
settings.json
src/node_modules
+admin/node_modules
diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml
index f41f5ac5e..0dd1000d8 100644
--- a/.github/workflows/backend-tests.yml
+++ b/.github/workflows/backend-tests.yml
@@ -54,6 +54,12 @@ jobs:
-
name: Install all dependencies and symlink for ep_etherpad-lite
run: bin/installDeps.sh
+ - name: Install admin ui
+ working-directory: admin
+ run: pnpm install
+ - name: Build admin ui
+ working-directory: admin
+ run: pnpm build
-
name: Run the backend tests
run: pnpm test
@@ -105,6 +111,12 @@ jobs:
-
name: Install all dependencies and symlink for ep_etherpad-lite
run: bin/installDeps.sh
+ - name: Install admin ui
+ working-directory: admin
+ run: pnpm install
+ - name: Build admin ui
+ working-directory: admin
+ run: pnpm build
-
name: Install Etherpad plugins
run: >
@@ -163,6 +175,12 @@ jobs:
-
name: Install all dependencies and symlink for ep_etherpad-lite
run: bin/installOnWindows.bat
+ - name: Install admin ui
+ working-directory: admin
+ run: pnpm install
+ - name: Build admin ui
+ working-directory: admin
+ run: pnpm build
-
name: Fix up the settings.json
run: |
@@ -207,6 +225,12 @@ jobs:
${{ runner.os }}-pnpm-store-
- name: Only install direct dependencies
run: pnpm config set auto-install-peers false
+ - name: Install admin ui
+ working-directory: admin
+ run: pnpm install
+ - name: Build admin ui
+ working-directory: admin
+ run: pnpm build
-
name: Install Etherpad plugins
# The --legacy-peer-deps flag is required to work around a bug in npm
diff --git a/.github/workflows/frontend-admin-tests.yml b/.github/workflows/frontend-admin-tests.yml
index aa1c4e70a..37c8ede08 100644
--- a/.github/workflows/frontend-admin-tests.yml
+++ b/.github/workflows/frontend-admin-tests.yml
@@ -12,7 +12,6 @@ jobs:
name: with plugins
runs-on: ubuntu-latest
-# node: [16, 19, 20] >> Disabled node 16 and 18 because they do not work
strategy:
fail-fast: false
matrix:
@@ -83,11 +82,11 @@ jobs:
run: "sed -i 's/\"enableAdminUITests\": false/\"enableAdminUITests\": true,\\n\"users\":{\"admin\":{\"password\":\"changeme\",\"is_admin\":true}}/' settings.json"
-
name: increase maxHttpBufferSize
- run: "sed -i 's/\"maxHttpBufferSize\": 10000/\"maxHttpBufferSize\": 100000/' settings.json"
+ run: "sed -i 's/\"maxHttpBufferSize\": 10000/\"maxHttpBufferSize\": 10000000/' settings.json"
-
name: Disable import/export rate limiting
run: |
- sed -e '/^ *"importExportRateLimiting":/,/^ *\}/ s/"max":.*/"max": 1000000/' -i settings.json
+ sed -e '/^ *"importExportRateLimiting":/,/^ *\}/ s/"max":.*/"max": 100000000/' -i settings.json
-
name: Remove standard frontend test files, so only admin tests are run
run: mv src/tests/frontend/specs/* /tmp && mv /tmp/admin*.js src/tests/frontend/specs
diff --git a/.gitignore b/.gitignore
index 38e2889d9..2a8335497 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,4 +23,5 @@ out/
/src/bin/etherpad-1.deb
/src/bin/node.exe
plugin_packages
-pnpm-lock.yaml
\ No newline at end of file
+pnpm-lock.yaml
+/src/templates/admin
diff --git a/Dockerfile b/Dockerfile
index 7cd8105d9..35e4665b2 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -4,6 +4,13 @@
#
# Author: muxator
+FROM node:alpine as adminBuild
+
+WORKDIR /opt/etherpad-lite
+COPY ./admin ./admin
+RUN cd ./admin && npm install -g pnpm && pnpm install && pnpm run build --outDir ./dist
+
+
FROM node:alpine as build
LABEL maintainer="Etherpad team, https://github.com/ether/etherpad-lite"
@@ -99,16 +106,18 @@ COPY --chown=etherpad:etherpad ./pnpm-workspace.yaml ./package.json ./
FROM build as development
COPY --chown=etherpad:etherpad ./src/package.json .npmrc ./src/pnpm-lock.yaml ./src/
+COPY --chown=etherpad:etherpad --from=adminBuild /opt/etherpad-lite/admin/dist ./src/templates/admin
RUN bin/installDeps.sh && { [ -z "${ETHERPAD_PLUGINS}" ] || \
pnpm install --workspace-root ${ETHERPAD_PLUGINS}; }
-
+
FROM build as production
ENV NODE_ENV=production
ENV ETHERPAD_PRODUCTION=true
COPY --chown=etherpad:etherpad ./src ./src
+COPY --chown=etherpad:etherpad --from=adminBuild /opt/etherpad-lite/admin/dist ./src/templates/admin
RUN bin/installDeps.sh && { [ -z "${ETHERPAD_PLUGINS}" ] || \
pnpm install --workspace-root ${ETHERPAD_PLUGINS}; } && \
diff --git a/admin/.eslintrc.cjs b/admin/.eslintrc.cjs
new file mode 100644
index 000000000..d6c953795
--- /dev/null
+++ b/admin/.eslintrc.cjs
@@ -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 },
+ ],
+ },
+}
diff --git a/admin/.gitignore b/admin/.gitignore
new file mode 100644
index 000000000..a547bf36d
--- /dev/null
+++ b/admin/.gitignore
@@ -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?
diff --git a/admin/README.md b/admin/README.md
new file mode 100644
index 000000000..0d6babedd
--- /dev/null
+++ b/admin/README.md
@@ -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
diff --git a/admin/index.html b/admin/index.html
new file mode 100644
index 000000000..8863894ed
--- /dev/null
+++ b/admin/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+ Etherpad Admin Dashboard
+
+
+
+
+
+
+
+
diff --git a/admin/package.json b/admin/package.json
new file mode 100644
index 000000000..d74fedc12
--- /dev/null
+++ b/admin/package.json
@@ -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"
+ }
+}
diff --git a/admin/pnpm-workspace.yaml b/admin/pnpm-workspace.yaml
new file mode 100644
index 000000000..e69de29bb
diff --git a/admin/public/ep_admin_pads/ar.json b/admin/public/ep_admin_pads/ar.json
new file mode 100644
index 000000000..746946edf
--- /dev/null
+++ b/admin/public/ep_admin_pads/ar.json
@@ -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": "حالة غير معروفة"
+}
diff --git a/admin/public/ep_admin_pads/bn.json b/admin/public/ep_admin_pads/bn.json
new file mode 100644
index 000000000..0048b52bb
--- /dev/null
+++ b/admin/public/ep_admin_pads/bn.json
@@ -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": "অজানা অবস্থা"
+}
diff --git a/admin/public/ep_admin_pads/ca.json b/admin/public/ep_admin_pads/ca.json
new file mode 100644
index 000000000..1d4e34216
--- /dev/null
+++ b/admin/public/ep_admin_pads/ca.json
@@ -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": "S’està 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"
+}
diff --git a/admin/public/ep_admin_pads/cs.json b/admin/public/ep_admin_pads/cs.json
new file mode 100644
index 000000000..19e92894d
--- /dev/null
+++ b/admin/public/ep_admin_pads/cs.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/cy.json b/admin/public/ep_admin_pads/cy.json
new file mode 100644
index 000000000..02546da90
--- /dev/null
+++ b/admin/public/ep_admin_pads/cy.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/da.json b/admin/public/ep_admin_pads/da.json
new file mode 100644
index 000000000..a5303b9cb
--- /dev/null
+++ b/admin/public/ep_admin_pads/da.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/de.json b/admin/public/ep_admin_pads/de.json
new file mode 100644
index 000000000..afb553caf
--- /dev/null
+++ b/admin/public/ep_admin_pads/de.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/diq.json b/admin/public/ep_admin_pads/diq.json
new file mode 100644
index 000000000..983680965
--- /dev/null
+++ b/admin/public/ep_admin_pads/diq.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/dsb.json b/admin/public/ep_admin_pads/dsb.json
new file mode 100644
index 000000000..363732a20
--- /dev/null
+++ b/admin/public/ep_admin_pads/dsb.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/el.json b/admin/public/ep_admin_pads/el.json
new file mode 100644
index 000000000..77b6af3dd
--- /dev/null
+++ b/admin/public/ep_admin_pads/el.json
@@ -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": "Άγνωστη κατάσταση"
+}
diff --git a/admin/public/ep_admin_pads/en.json b/admin/public/ep_admin_pads/en.json
new file mode 100644
index 000000000..8a9044b1b
--- /dev/null
+++ b/admin/public/ep_admin_pads/en.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/eu.json b/admin/public/ep_admin_pads/eu.json
new file mode 100644
index 000000000..71d9dfe79
--- /dev/null
+++ b/admin/public/ep_admin_pads/eu.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/ff.json b/admin/public/ep_admin_pads/ff.json
new file mode 100644
index 000000000..8cb5aea99
--- /dev/null
+++ b/admin/public/ep_admin_pads/ff.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/fi.json b/admin/public/ep_admin_pads/fi.json
new file mode 100644
index 000000000..708b2bef8
--- /dev/null
+++ b/admin/public/ep_admin_pads/fi.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/fr.json b/admin/public/ep_admin_pads/fr.json
new file mode 100644
index 000000000..e6c8a8703
--- /dev/null
+++ b/admin/public/ep_admin_pads/fr.json
@@ -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 d’utilisateurs 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 d’obtention 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"
+}
diff --git a/admin/public/ep_admin_pads/gl.json b/admin/public/ep_admin_pads/gl.json
new file mode 100644
index 000000000..5e6b66549
--- /dev/null
+++ b/admin/public/ep_admin_pads/gl.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/he.json b/admin/public/ep_admin_pads/he.json
new file mode 100644
index 000000000..8b506946b
--- /dev/null
+++ b/admin/public/ep_admin_pads/he.json
@@ -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": "מצב לא ידוע"
+}
diff --git a/admin/public/ep_admin_pads/hsb.json b/admin/public/ep_admin_pads/hsb.json
new file mode 100644
index 000000000..a6c29611f
--- /dev/null
+++ b/admin/public/ep_admin_pads/hsb.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/hu.json b/admin/public/ep_admin_pads/hu.json
new file mode 100644
index 000000000..9210761bc
--- /dev/null
+++ b/admin/public/ep_admin_pads/hu.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/ia.json b/admin/public/ep_admin_pads/ia.json
new file mode 100644
index 000000000..f0e00e5ca
--- /dev/null
+++ b/admin/public/ep_admin_pads/ia.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/it.json b/admin/public/ep_admin_pads/it.json
new file mode 100644
index 000000000..493cbb4d5
--- /dev/null
+++ b/admin/public/ep_admin_pads/it.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/kn.json b/admin/public/ep_admin_pads/kn.json
new file mode 100644
index 000000000..1e9019611
--- /dev/null
+++ b/admin/public/ep_admin_pads/kn.json
@@ -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": "ಅಪರಿಚಿತ ದೋಷ"
+}
diff --git a/admin/public/ep_admin_pads/ko.json b/admin/public/ep_admin_pads/ko.json
new file mode 100644
index 000000000..9ab8feed3
--- /dev/null
+++ b/admin/public/ep_admin_pads/ko.json
@@ -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": "알 수 없는 상태"
+}
diff --git a/admin/public/ep_admin_pads/krc.json b/admin/public/ep_admin_pads/krc.json
new file mode 100644
index 000000000..2caf4f099
--- /dev/null
+++ b/admin/public/ep_admin_pads/krc.json
@@ -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": "Билинмеген турум"
+}
diff --git a/admin/public/ep_admin_pads/lb.json b/admin/public/ep_admin_pads/lb.json
new file mode 100644
index 000000000..61aa2588d
--- /dev/null
+++ b/admin/public/ep_admin_pads/lb.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/lt.json b/admin/public/ep_admin_pads/lt.json
new file mode 100644
index 000000000..59b2a13b3
--- /dev/null
+++ b/admin/public/ep_admin_pads/lt.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/mk.json b/admin/public/ep_admin_pads/mk.json
new file mode 100644
index 000000000..72affd86c
--- /dev/null
+++ b/admin/public/ep_admin_pads/mk.json
@@ -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": "Непозната состојба"
+}
diff --git a/admin/public/ep_admin_pads/my.json b/admin/public/ep_admin_pads/my.json
new file mode 100644
index 000000000..6b94ba702
--- /dev/null
+++ b/admin/public/ep_admin_pads/my.json
@@ -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": "အခြေအနေမသိ"
+}
diff --git a/admin/public/ep_admin_pads/nb.json b/admin/public/ep_admin_pads/nb.json
new file mode 100644
index 000000000..acd194397
--- /dev/null
+++ b/admin/public/ep_admin_pads/nb.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/nl.json b/admin/public/ep_admin_pads/nl.json
new file mode 100644
index 000000000..f4d97b351
--- /dev/null
+++ b/admin/public/ep_admin_pads/nl.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/oc.json b/admin/public/ep_admin_pads/oc.json
new file mode 100644
index 000000000..ae0169faf
--- /dev/null
+++ b/admin/public/ep_admin_pads/oc.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/pms.json b/admin/public/ep_admin_pads/pms.json
new file mode 100644
index 000000000..ac0542b85
--- /dev/null
+++ b/admin/public/ep_admin_pads/pms.json
@@ -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ù"
+}
diff --git a/admin/public/ep_admin_pads/pt-br.json b/admin/public/ep_admin_pads/pt-br.json
new file mode 100644
index 000000000..28a7874ee
--- /dev/null
+++ b/admin/public/ep_admin_pads/pt-br.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/pt.json b/admin/public/ep_admin_pads/pt.json
new file mode 100644
index 000000000..b7abf2f3f
--- /dev/null
+++ b/admin/public/ep_admin_pads/pt.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/qqq.json b/admin/public/ep_admin_pads/qqq.json
new file mode 100644
index 000000000..de36e2ae6
--- /dev/null
+++ b/admin/public/ep_admin_pads/qqq.json
@@ -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}}"
+}
diff --git a/admin/public/ep_admin_pads/ru.json b/admin/public/ep_admin_pads/ru.json
new file mode 100644
index 000000000..6d0d163d0
--- /dev/null
+++ b/admin/public/ep_admin_pads/ru.json
@@ -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": "Неизвестный статус"
+}
diff --git a/admin/public/ep_admin_pads/sc.json b/admin/public/ep_admin_pads/sc.json
new file mode 100644
index 000000000..a37bba5a2
--- /dev/null
+++ b/admin/public/ep_admin_pads/sc.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/sdc.json b/admin/public/ep_admin_pads/sdc.json
new file mode 100644
index 000000000..c4672fd7f
--- /dev/null
+++ b/admin/public/ep_admin_pads/sdc.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/sk.json b/admin/public/ep_admin_pads/sk.json
new file mode 100644
index 000000000..ab0392d4e
--- /dev/null
+++ b/admin/public/ep_admin_pads/sk.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/skr-arab.json b/admin/public/ep_admin_pads/skr-arab.json
new file mode 100644
index 000000000..08162f849
--- /dev/null
+++ b/admin/public/ep_admin_pads/skr-arab.json
@@ -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": "نامعلوم حالت"
+}
diff --git a/admin/public/ep_admin_pads/sl.json b/admin/public/ep_admin_pads/sl.json
new file mode 100644
index 000000000..3bebe1972
--- /dev/null
+++ b/admin/public/ep_admin_pads/sl.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/smn.json b/admin/public/ep_admin_pads/smn.json
new file mode 100644
index 000000000..9d57cc73c
--- /dev/null
+++ b/admin/public/ep_admin_pads/smn.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/sms.json b/admin/public/ep_admin_pads/sms.json
new file mode 100644
index 000000000..8d3cf5797
--- /dev/null
+++ b/admin/public/ep_admin_pads/sms.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/sq.json b/admin/public/ep_admin_pads/sq.json
new file mode 100644
index 000000000..cc4740763
--- /dev/null
+++ b/admin/public/ep_admin_pads/sq.json
@@ -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": "S’ka 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": "S’u 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"
+}
diff --git a/admin/public/ep_admin_pads/sv.json b/admin/public/ep_admin_pads/sv.json
new file mode 100644
index 000000000..e77aaf2c4
--- /dev/null
+++ b/admin/public/ep_admin_pads/sv.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/sw.json b/admin/public/ep_admin_pads/sw.json
new file mode 100644
index 000000000..f1beeecbb
--- /dev/null
+++ b/admin/public/ep_admin_pads/sw.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/th.json b/admin/public/ep_admin_pads/th.json
new file mode 100644
index 000000000..693e3f797
--- /dev/null
+++ b/admin/public/ep_admin_pads/th.json
@@ -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": "ไม่ทราบสถานะ"
+}
diff --git a/admin/public/ep_admin_pads/tl.json b/admin/public/ep_admin_pads/tl.json
new file mode 100644
index 000000000..238e01236
--- /dev/null
+++ b/admin/public/ep_admin_pads/tl.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/tr.json b/admin/public/ep_admin_pads/tr.json
new file mode 100644
index 000000000..7e2e9d402
--- /dev/null
+++ b/admin/public/ep_admin_pads/tr.json
@@ -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"
+}
diff --git a/admin/public/ep_admin_pads/uk.json b/admin/public/ep_admin_pads/uk.json
new file mode 100644
index 000000000..c5c95f722
--- /dev/null
+++ b/admin/public/ep_admin_pads/uk.json
@@ -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": "Невідомий статус"
+}
diff --git a/admin/public/ep_admin_pads/zh-hans.json b/admin/public/ep_admin_pads/zh-hans.json
new file mode 100644
index 000000000..cdf0d945f
--- /dev/null
+++ b/admin/public/ep_admin_pads/zh-hans.json
@@ -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": "未知状态"
+}
diff --git a/admin/public/ep_admin_pads/zh-hant.json b/admin/public/ep_admin_pads/zh-hant.json
new file mode 100644
index 000000000..daeed55f5
--- /dev/null
+++ b/admin/public/ep_admin_pads/zh-hant.json
@@ -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": "不明狀態"
+}
diff --git a/admin/public/fond.jpg b/admin/public/fond.jpg
new file mode 100644
index 000000000..81357c7bb
Binary files /dev/null and b/admin/public/fond.jpg differ
diff --git a/admin/src/App.css b/admin/src/App.css
new file mode 100644
index 000000000..e69de29bb
diff --git a/admin/src/App.tsx b/admin/src/App.tsx
new file mode 100644
index 000000000..3db45f0e8
--- /dev/null
+++ b/admin/src/App.tsx
@@ -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
+}
+
+export default App
diff --git a/admin/src/index.css b/admin/src/index.css
new file mode 100644
index 000000000..e9683befa
--- /dev/null
+++ b/admin/src/index.css
@@ -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;
+}
diff --git a/admin/src/localization/i18n.ts b/admin/src/localization/i18n.ts
new file mode 100644
index 000000000..67ae140e7
--- /dev/null
+++ b/admin/src/localization/i18n.ts
@@ -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
diff --git a/admin/src/main.tsx b/admin/src/main.tsx
new file mode 100644
index 000000000..03ec73104
--- /dev/null
+++ b/admin/src/main.tsx
@@ -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(
+ <>}>
+ }/>
+ }/>
+ }/>
+ }/>
+ }/>
+
+ }/>
+ >
+), {
+ basename: import.meta.env.BASE_URL
+})
+
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+
+
+
+
+ ,
+)
diff --git a/admin/src/pages/HelpPage.tsx b/admin/src/pages/HelpPage.tsx
new file mode 100644
index 000000000..6f06907e1
--- /dev/null
+++ b/admin/src/pages/HelpPage.tsx
@@ -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();
+
+ useEffect(() => {
+ if(!settingsSocket) return;
+ settingsSocket?.on('reply:help', (data) => {
+ setHelpData(data)
+ });
+
+ settingsSocket?.emit('help');
+ }, [settingsSocket]);
+
+ const renderHooks = (hooks:Record>) => {
+ return Object.keys(hooks).map((hookName, i) => {
+ return
+
{hookName}
+
+ {Object.keys(hooks[hookName]).map((hook, i) => - {hook}
+
+ {Object.keys(hooks[hookName][hook]).map((subHook, i) => - {subHook}
)}
+
+ )}
+
+
+ })
+ }
+
+
+ if (!helpData) return
+
+ return
+
+
+
+
{helpData?.epVersion}
+
+
{helpData.latestVersion}
+
Git sha
+
{helpData.gitCommit}
+
+
+
+ {helpData.installedPlugins.map((plugin, i) => - {plugin}
)}
+
+
+
+
+ {helpData.installedParts.map((part, i) => - {part}
)}
+
+
+
+ {
+ renderHooks(helpData.installedServerHooks)
+ }
+
+
+
+ {
+ renderHooks(helpData.installedClientHooks)
+ }
+
+
+
+}
diff --git a/admin/src/pages/HomePage.tsx b/admin/src/pages/HomePage.tsx
new file mode 100644
index 000000000..fd29e390e
--- /dev/null
+++ b/admin/src/pages/HomePage.tsx
@@ -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([])
+ const [installedPlugins, setInstalledPlugins] = useState([])
+ const [searchParams, setSearchParams] = useState({
+ offset: 0,
+ limit: 99999,
+ sortBy: 'name',
+ sortDir: 'asc',
+ searchTerm: ''
+ })
+ const [searchTerm, setSearchTerm] = useState('')
+ 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
+
+
+
+
+
+
+
+ |
+ |
+ |
+
+
+
+ {installedPlugins.map((plugin, index) => {
+ return
+ {plugin.name} |
+ {plugin.version} |
+
+ {
+ plugin.updatable ?
+
+ :
+
+ }
+ |
+
+ })}
+
+
+
+
+
+
+
{
+ setSearchTerm(v.target.value)
+ }}/>
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+
+
+
+ {plugins.map((plugin) => {
+ return
+ {plugin.name} |
+ {plugin.description} |
+ {plugin.version} |
+ {plugin.time} |
+
+
+ |
+
+ })}
+
+
+
+}
diff --git a/admin/src/pages/LoginScreen.tsx b/admin/src/pages/LoginScreen.tsx
new file mode 100644
index 000000000..7860368f8
--- /dev/null
+++ b/admin/src/pages/LoginScreen.tsx
@@ -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
+}
diff --git a/admin/src/pages/PadPage.tsx b/admin/src/pages/PadPage.tsx
new file mode 100644
index 000000000..5c11755d6
--- /dev/null
+++ b/admin/src/pages/PadPage.tsx
@@ -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({
+ offset: 0,
+ limit: 12,
+ pattern: '',
+ sortBy: 'padName',
+ ascending: true
+ })
+ const {t} = useTranslation()
+ const [searchTerm, setSearchTerm] = useState('')
+ 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(false)
+ const [padToDelete, setPadToDelete] = useState('')
+
+ 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
+
+
+
+
+
+
+ {t("ep_admin_pads:ep_adminpads2_confirm", {
+ padID: padToDelete,
+ })}
+
+
+
+
+
+
+
+
+
+
+
setSearchTerm(v.target.value)}
+ placeholder={t('ep_admin_pads:ep_adminpads2_search-heading')}/>
+
+
+
+ {
+ setSearchParams({
+ ...searchParams,
+ sortBy: 'padName',
+ ascending: !searchParams.ascending
+ })
+ }}> |
+ {
+ setSearchParams({
+ ...searchParams,
+ sortBy: 'lastEdited',
+ ascending: !searchParams.ascending
+ })
+ }}> |
+ {
+ setSearchParams({
+ ...searchParams,
+ sortBy: 'userCount',
+ ascending: !searchParams.ascending
+ })
+ }}> |
+ {
+ setSearchParams({
+ ...searchParams,
+ sortBy: 'revisionNumber',
+ ascending: !searchParams.ascending
+ })
+ }}>Revision number |
+ |
+
+
+
+ {
+ pads?.results?.map((pad)=>{
+ return
+ {pad.padName} |
+ {pad.userCount} |
+ {new Date(pad.lastEdited).toLocaleString()} |
+ {pad.revisionNumber} |
+
+
+
+
+
+ |
+
+ })
+ }
+
+
+
+ {pages.map((page)=>{
+ return
+ })}
+
+
+}
diff --git a/admin/src/pages/Plugin.ts b/admin/src/pages/Plugin.ts
new file mode 100644
index 000000000..3188c247f
--- /dev/null
+++ b/admin/src/pages/Plugin.ts
@@ -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>,
+ installedParts: string[],
+ installedPlugins: string[],
+ installedServerHooks: Record,
+ latestVersion: string
+}
diff --git a/admin/src/pages/SettingsPage.tsx b/admin/src/pages/SettingsPage.tsx
new file mode 100644
index 000000000..b72507c3a
--- /dev/null
+++ b/admin/src/pages/SettingsPage.tsx
@@ -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
+}
diff --git a/admin/src/store/store.ts b/admin/src/store/store.ts
new file mode 100644
index 000000000..d662dfdf3
--- /dev/null
+++ b/admin/src/store/store.ts
@@ -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()((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})
+}));
diff --git a/admin/src/utils/AnimationFrameHook.ts b/admin/src/utils/AnimationFrameHook.ts
new file mode 100644
index 000000000..98ae324e8
--- /dev/null
+++ b/admin/src/utils/AnimationFrameHook.ts
@@ -0,0 +1,29 @@
+import {useCallback, useEffect, useRef} from "react";
+
+type Args = any[]
+
+export const useAnimationFrame = void>(
+ callback: Fn,
+ wait = 0
+): ((...args: Parameters)=>void)=>{
+ const rafId = useRef(0)
+ const render = useCallback(
+ (...args: Parameters)=>{
+ cancelAnimationFrame(rafId.current)
+ const timeStart = performance.now()
+
+ const renderFrame = (timeNow: number)=>{
+ if(timeNow-timeStartcancelAnimationFrame(rafId.current),[])
+ return render
+}
\ No newline at end of file
diff --git a/admin/src/utils/LoadingScreen.tsx b/admin/src/utils/LoadingScreen.tsx
new file mode 100644
index 000000000..a234dfc38
--- /dev/null
+++ b/admin/src/utils/LoadingScreen.tsx
@@ -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
+
+
+
+
+
+
+}
diff --git a/admin/src/utils/PadSearch.ts b/admin/src/utils/PadSearch.ts
new file mode 100644
index 000000000..6491094a2
--- /dev/null
+++ b/admin/src/utils/PadSearch.ts
@@ -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;
+}
diff --git a/admin/src/utils/Toast.tsx b/admin/src/utils/Toast.tsx
new file mode 100644
index 000000000..7b74b4d2b
--- /dev/null
+++ b/admin/src/utils/Toast.tsx
@@ -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 <>
+ {
+ useStore.getState().setToastState({
+ ...toastState!,
+ open: !toastState?.open
+ })
+ }}>
+ {toastState.title}
+
+ {toastState.description}
+
+
+
+ >
+}
diff --git a/admin/src/utils/brand.svg b/admin/src/utils/brand.svg
new file mode 100644
index 000000000..3f42434c9
--- /dev/null
+++ b/admin/src/utils/brand.svg
@@ -0,0 +1,50 @@
+
+
diff --git a/admin/src/utils/sorting.ts b/admin/src/utils/sorting.ts
new file mode 100644
index 000000000..cc6a9a293
--- /dev/null
+++ b/admin/src/utils/sorting.ts
@@ -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';
+}
diff --git a/admin/src/utils/useDebounce.ts b/admin/src/utils/useDebounce.ts
new file mode 100644
index 000000000..fcf56a093
--- /dev/null
+++ b/admin/src/utils/useDebounce.ts
@@ -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)
+}
\ No newline at end of file
diff --git a/admin/src/utils/utils.ts b/admin/src/utils/utils.ts
new file mode 100644
index 000000000..2e8f52a05
--- /dev/null
+++ b/admin/src/utils/utils.ts
@@ -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
+ }
+};
diff --git a/admin/src/vite-env.d.ts b/admin/src/vite-env.d.ts
new file mode 100644
index 000000000..b1f45c786
--- /dev/null
+++ b/admin/src/vite-env.d.ts
@@ -0,0 +1,2 @@
+///
+///
diff --git a/admin/tsconfig.json b/admin/tsconfig.json
new file mode 100644
index 000000000..a7fc6fbf2
--- /dev/null
+++ b/admin/tsconfig.json
@@ -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" }]
+}
diff --git a/admin/tsconfig.node.json b/admin/tsconfig.node.json
new file mode 100644
index 000000000..97ede7ee6
--- /dev/null
+++ b/admin/tsconfig.node.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "strict": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/admin/vite.config.ts b/admin/vite.config.ts
new file mode 100644
index 000000000..ff329032f
--- /dev/null
+++ b/admin/vite.config.ts
@@ -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/')
+ }
+ }
+ }
+})
diff --git a/bin/buildForWindows.sh b/bin/buildForWindows.sh
index e49a3da69..6c7d7f817 100755
--- a/bin/buildForWindows.sh
+++ b/bin/buildForWindows.sh
@@ -41,7 +41,7 @@ try cd "${TMP_FOLDER}"
# setting NODE_ENV=production ensures that dev dependencies are not installed,
# making the windows package smaller
-export NODE_ENV=production
+export NODE_ENV=development
rm -rf node_modules || true
rm -rf src/node_modules || true
@@ -49,6 +49,15 @@ rm -rf src/node_modules || true
#log "do a normal unix install first..."
#$(try cd ./bin/installDeps.sh)
+# Install admin frontend
+cd admin
+try pnpm install
+try pnpm run build
+cd ..
+
+# Nuke the admin folder as it is not needed anymore :D
+rm -rf admin
+
log "copy the windows settings template..."
try cp settings.json.template settings.json
diff --git a/bin/installOnWindows.bat b/bin/installOnWindows.bat
index bf8e6a321..5b04919bd 100644
--- a/bin/installOnWindows.bat
+++ b/bin/installOnWindows.bat
@@ -9,11 +9,17 @@ cmd /C node -e "" || ( echo "Please install node.js ( https://nodejs.org )" && e
echo _
echo Ensure that all dependencies are up to date... If this is the first time you have run Etherpad please be patient.
-mkdir node_modules
-cd /D node_modules
-mklink /D "ep_etherpad-lite" "..\src"
-cd /D ..\src
+:: Install admin ui only if available
+IF EXIST admin (
+ cd /D .\admin
+ dir
+ cmd /C pnpm i || exit /B 1
+ cmd /C pnpm run build || exit /B 1
+ cd /D ..
+)
+
+
cmd /C pnpm i || exit /B 1
cd /D "%~dp0\.."
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index ced0baca4..4d8f70bb3 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,2 +1,2 @@
packages:
- - src
\ No newline at end of file
+ - src
diff --git a/src/.eslintrc.cjs b/src/.eslintrc.cjs
index 95c9efa07..03d432ede 100644
--- a/src/.eslintrc.cjs
+++ b/src/.eslintrc.cjs
@@ -5,8 +5,6 @@ require('eslint-config-etherpad/patch/modern-module-resolution');
module.exports = {
ignorePatterns: [
- '/static/js/admin/jquery.autosize.js',
- '/static/js/admin/minify.json.js',
'/static/js/vendors/browser.js',
'/static/js/vendors/farbtastic.js',
'/static/js/vendors/gritter.js',
diff --git a/src/ep.json b/src/ep.json
index ec09696c5..f6d41e203 100644
--- a/src/ep.json
+++ b/src/ep.json
@@ -92,14 +92,12 @@
{
"name": "adminplugins",
"hooks": {
- "expressCreateServer": "ep_etherpad-lite/node/hooks/express/adminplugins",
"socketio": "ep_etherpad-lite/node/hooks/express/adminplugins"
}
},
{
"name": "adminsettings",
"hooks": {
- "expressCreateServer": "ep_etherpad-lite/node/hooks/express/adminsettings",
"socketio": "ep_etherpad-lite/node/hooks/express/adminsettings"
}
},
diff --git a/src/node/db/PadManager.ts b/src/node/db/PadManager.ts
index c0c4b0f6a..54dbbf089 100644
--- a/src/node/db/PadManager.ts
+++ b/src/node/db/PadManager.ts
@@ -20,6 +20,7 @@
*/
import {MapArrayType} from "../types/MapType";
+import {PadType} from "../types/PadType";
const CustomError = require('../utils/customError');
const Pad = require('../db/Pad');
@@ -105,7 +106,7 @@ const padList = new class {
* @param {string} [authorId] - Optional author ID of the user that initiated the pad creation (if
* applicable).
*/
-exports.getPad = async (id: string, text: string|null, authorId:string = '') => {
+exports.getPad = async (id: string, text?: string|null, authorId:string|null = ''):Promise => {
// check if this is a valid padId
if (!exports.isValidPadId(id)) {
throw new CustomError(`${id} is not a valid padId`, 'apierror');
@@ -148,6 +149,9 @@ exports.listAllPads = async () => {
return {padIDs};
};
+
+
+
// checks if a pad exists
exports.doesPadExist = async (padId: string) => {
const value = await db.get(`pad:${padId}`);
diff --git a/src/node/hooks/express/admin.ts b/src/node/hooks/express/admin.ts
index 90e491396..3c85d072a 100644
--- a/src/node/hooks/express/admin.ts
+++ b/src/node/hooks/express/admin.ts
@@ -1,7 +1,9 @@
'use strict';
import {ArgsExpressType} from "../../types/ArgsExpressType";
+import path from "path";
+const settings = require('ep_etherpad-lite/node/utils/Settings');
-const eejs = require('../../eejs');
+const ADMIN_PATH = path.join(settings.root, 'src', 'templates', 'admin');
/**
* Add the admin navigation link
@@ -11,9 +13,19 @@ const eejs = require('../../eejs');
* @return {*}
*/
exports.expressCreateServer = (hookName:string, args: ArgsExpressType, cb:Function): any => {
- args.app.get('/admin', (req:any, res:any) => {
- if ('/' !== req.path[req.path.length - 1]) return res.redirect('./admin/');
- res.send(eejs.require('ep_etherpad-lite/templates/admin/index.html', {req}));
- });
+ args.app.get('/admin/*', (req:any, res:any, next:Function) => {
+ if (req.path.includes('.')) {
+ const relativPath = req.path.split('/admin/')[1];
+ res.sendFile(path.join(ADMIN_PATH, relativPath));
+ } else {
+ res.header('Cache-Control', 'private, no-cache, no-store, must-revalidate');
+ res.header('Expires', '-1');
+ res.header('Pragma', 'no-cache');
+ res.sendFile(path.join(ADMIN_PATH, 'index.html'));
+ }
+ });
+ args.app.get('/admin', (req:any, res:any, next:Function) => {
+ if ('/' !== req.path[req.path.length - 1]) return res.redirect('./admin/');
+ })
return cb();
};
diff --git a/src/node/hooks/express/adminplugins.ts b/src/node/hooks/express/adminplugins.ts
index ad1795e17..dc34cd437 100644
--- a/src/node/hooks/express/adminplugins.ts
+++ b/src/node/hooks/express/adminplugins.ts
@@ -12,35 +12,7 @@ const installer = require('../../../static/js/pluginfw/installer');
const pluginDefs = require('../../../static/js/pluginfw/plugin_defs');
const plugins = require('../../../static/js/pluginfw/plugins');
const semver = require('semver');
-const UpdateCheck = require('../../utils/UpdateCheck');
-exports.expressCreateServer = (hookName:string, args: ArgsExpressType, cb:Function) => {
- args.app.get('/admin/plugins', (req:any, res:any) => {
- res.send(eejs.require('ep_etherpad-lite/templates/admin/plugins.html', {
- plugins: pluginDefs.plugins,
- req,
- errors: [],
- }));
- });
-
- args.app.get('/admin/plugins/info', (req:any, res:any) => {
- const gitCommit = settings.getGitCommit();
- const epVersion = settings.getEpVersion();
-
- res.send(eejs.require('ep_etherpad-lite/templates/admin/plugins-info.html', {
- gitCommit,
- epVersion,
- installedPlugins: `${plugins.formatPlugins().replace(/, /g, '\n')}
`,
- installedParts: `${plugins.formatParts()}
`,
- installedServerHooks: `${plugins.formatHooks('hooks', true)}
`,
- installedClientHooks: `${plugins.formatHooks('client_hooks', true)}
`,
- latestVersion: UpdateCheck.getLatestVersion(),
- req,
- }));
- });
-
- return cb();
-};
exports.socketio = (hookName:string, args:ArgsExpressType, cb:Function) => {
const io = args.io.of('/pluginfw/installer');
diff --git a/src/node/hooks/express/adminsettings.ts b/src/node/hooks/express/adminsettings.ts
index 900bfd479..03258c584 100644
--- a/src/node/hooks/express/adminsettings.ts
+++ b/src/node/hooks/express/adminsettings.ts
@@ -1,20 +1,21 @@
'use strict';
+
+import {PadQueryResult, PadSearchQuery} from "../../types/PadSearchQuery";
+import {PadType} from "../../types/PadType";
+
const eejs = require('../../eejs');
const fsp = require('fs').promises;
const hooks = require('../../../static/js/pluginfw/hooks');
const plugins = require('../../../static/js/pluginfw/plugins');
const settings = require('../../utils/Settings');
+const UpdateCheck = require('../../utils/UpdateCheck');
+const padManager = require('../../db/PadManager');
+const api = require('../../db/API');
+
+
+const queryPadLimit = 12;
-exports.expressCreateServer = (hookName:string, {app}:any) => {
- app.get('/admin/settings', (req:any, res:any) => {
- res.send(eejs.require('ep_etherpad-lite/templates/admin/settings.html', {
- req,
- settings: '',
- errors: [],
- }));
- });
-};
exports.socketio = (hookName:string, {io}:any) => {
io.of('/settings').on('connection', (socket: any ) => {
@@ -38,10 +39,160 @@ exports.socketio = (hookName:string, {io}:any) => {
});
socket.on('saveSettings', async (newSettings:string) => {
+ console.log('Admin request to save settings through a socket on /admin/settings');
await fsp.writeFile(settings.settingsFilename, newSettings);
socket.emit('saveprogress', 'saved');
});
+
+ socket.on('help', ()=> {
+ const gitCommit = settings.getGitCommit();
+ const epVersion = settings.getEpVersion();
+
+ const hooks:Map> = plugins.getHooks('hooks', false);
+ const clientHooks:Map> = plugins.getHooks('client_hooks', false);
+
+ function mapToObject(map: Map) {
+ let obj = Object.create(null);
+ for (let [k,v] of map) {
+ if(v instanceof Map) {
+ obj[k] = mapToObject(v);
+ } else {
+ obj[k] = v;
+ }
+ }
+ return obj;
+ }
+
+ socket.emit('reply:help', {
+ gitCommit,
+ epVersion,
+ installedPlugins: plugins.getPlugins(),
+ installedParts: plugins.getParts(),
+ installedServerHooks: mapToObject(hooks),
+ installedClientHooks: mapToObject(clientHooks),
+ latestVersion: UpdateCheck.getLatestVersion(),
+ })
+ });
+
+
+ socket.on('padLoad', async (query: PadSearchQuery) => {
+ const {padIDs} = await padManager.listAllPads();
+
+ const data:{
+ total: number,
+ results?: PadQueryResult[]
+ } = {
+ total: padIDs.length,
+ };
+ let result: string[] = padIDs;
+ let maxResult;
+
+ // Filter out matches
+ if (query.pattern) {
+ result = result.filter((padName: string) => padName.includes(query.pattern));
+ }
+
+ data.total = result.length;
+
+ maxResult = result.length - 1;
+ if (maxResult < 0) {
+ maxResult = 0;
+ }
+
+ if (query.offset && query.offset < 0) {
+ query.offset = 0;
+ } else if (query.offset > maxResult) {
+ query.offset = maxResult;
+ }
+
+ if (query.limit && query.limit < 0) {
+ query.limit = 0;
+ } else if (query.limit > queryPadLimit) {
+ query.limit = queryPadLimit;
+ }
+
+ if (query.sortBy === 'padName') {
+ result = result.sort((a,b)=>{
+ if(a < b) return query.ascending ? -1 : 1;
+ if(a > b) return query.ascending ? 1 : -1;
+ return 0;
+ }).slice(query.offset, query.offset + query.limit);
+
+ data.results = await Promise.all(result.map(async (padName: string) => {
+ const pad = await padManager.getPad(padName);
+ const revisionNumber = pad.getHeadRevisionNumber()
+ const userCount = api.padUsersCount(padName).padUsersCount;
+ const lastEdited = await pad.getLastEdit();
+
+ return {
+ padName,
+ lastEdited,
+ userCount,
+ revisionNumber
+ }}));
+ } else {
+ const currentWinners: PadQueryResult[] = []
+ let queryOffsetCounter = 0
+ for (let res of result) {
+
+ const pad = await padManager.getPad(res);
+ const padType = {
+ padName: res,
+ lastEdited: await pad.getLastEdit(),
+ userCount: api.padUsersCount(res).padUsersCount,
+ revisionNumber: pad.getHeadRevisionNumber()
+ };
+
+ if (currentWinners.length < query.limit) {
+ if(queryOffsetCounter < query.offset){
+ queryOffsetCounter++
+ continue
+ }
+ currentWinners.push({
+ padName: res,
+ lastEdited: await pad.getLastEdit(),
+ userCount: api.padUsersCount(res).padUsersCount,
+ revisionNumber: pad.getHeadRevisionNumber()
+ })
+ } else {
+ // Kick out worst pad and replace by current pad
+ let worstPad = currentWinners.sort((a, b) => {
+ if (a[query.sortBy] < b[query.sortBy]) return query.ascending ? -1 : 1;
+ if (a[query.sortBy] > b[query.sortBy]) return query.ascending ? 1 : -1;
+ return 0;
+ })
+ if(worstPad[0]&&worstPad[0][query.sortBy] < padType[query.sortBy]){
+ if(queryOffsetCounter < query.offset){
+ queryOffsetCounter++
+ continue
+ }
+ currentWinners.splice(currentWinners.indexOf(worstPad[0]), 1)
+ currentWinners.push({
+ padName: res,
+ lastEdited: await pad.getLastEdit(),
+ userCount: api.padUsersCount(res).padUsersCount,
+ revisionNumber: pad.getHeadRevisionNumber()
+ })
+ }
+ }
+ }
+ data.results = currentWinners;
+ }
+
+ socket.emit('results:padLoad', data);
+ })
+
+
+ socket.on('deletePad', async (padId: string) => {
+ const padExists = await padManager.doesPadExists(padId);
+ if (padExists) {
+ const pad = await padManager.getPad(padId);
+ await pad.remove();
+ socket.emit('results:deletePad', padId);
+ }
+ })
+
socket.on('restartServer', async () => {
console.log('Admin request to restart server through a socket on /admin/settings');
settings.reloadSettings();
@@ -51,3 +202,10 @@ exports.socketio = (hookName:string, {io}:any) => {
});
});
};
+
+
+
+const searchPad = async (query:PadSearchQuery) => {
+
+}
+
diff --git a/src/node/hooks/express/socketio.ts b/src/node/hooks/express/socketio.ts
index 961e2cfd3..e64a16847 100644
--- a/src/node/hooks/express/socketio.ts
+++ b/src/node/hooks/express/socketio.ts
@@ -76,10 +76,12 @@ export const expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Fu
maxHttpBufferSize: settings.socketIo.maxHttpBufferSize,
})
- io.on('connection', (socket:any) => {
+
+ const handleConnection = (socket:Socket) => {
sockets.add(socket);
socketsEvents.emit('updated');
// https://socket.io/docs/v3/faq/index.html
+ // @ts-ignore
const session = socket.request.session;
session.connections++;
session.save();
@@ -87,15 +89,9 @@ export const expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Fu
sockets.delete(socket);
socketsEvents.emit('updated');
});
- });
+ }
- io.use(socketSessionMiddleware(args));
-
- // Temporary workaround so all clients go through middleware and handle connection
- io.of('/pluginfw/installer').use(socketSessionMiddleware(args))
- io.of('/settings').use(socketSessionMiddleware(args))
-
- io.use((socket:any, next:Function) => {
+ const renewSession = (socket:any, next:Function) => {
socket.conn.on('packet', (packet:string) => {
// Tell express-session that the session is still active. The session store can use these
// touch events to defer automatic session cleanup, and if express-session is configured with
@@ -106,7 +102,24 @@ export const expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Fu
if (socket.request.session != null) socket.request.session.touch();
});
next();
- });
+ }
+
+
+ io.on('connection', handleConnection);
+
+ io.use(socketSessionMiddleware(args));
+
+ // Temporary workaround so all clients go through middleware and handle connection
+ io.of('/pluginfw/installer')
+ .on('connection',handleConnection)
+ .use(socketSessionMiddleware(args))
+ .use(renewSession)
+ io.of('/settings')
+ .on('connection',handleConnection)
+ .use(socketSessionMiddleware(args))
+ .use(renewSession)
+
+ io.use(renewSession);
// var socketIOLogger = log4js.getLogger("socket.io");
// Debug logging now has to be set at an environment level, this is stupid.
diff --git a/src/node/hooks/express/webaccess.ts b/src/node/hooks/express/webaccess.ts
index 43bdea5fc..0034f87c8 100644
--- a/src/node/hooks/express/webaccess.ts
+++ b/src/node/hooks/express/webaccess.ts
@@ -50,7 +50,7 @@ exports.userCanModify = (padId: string, req: SocketClientRequest) => {
exports.authnFailureDelayMs = 1000;
const checkAccess = async (req:any, res:any, next: Function) => {
- const requireAdmin = req.path.toLowerCase().startsWith('/admin');
+ const requireAdmin = req.path.toLowerCase().startsWith('/admin-auth');
// ///////////////////////////////////////////////////////////////////////////////////////////////
// Step 1: Check the preAuthorize hook for early permit/deny (permit is only allowed for non-admin
@@ -126,7 +126,13 @@ const checkAccess = async (req:any, res:any, next: Function) => {
// completed, or maybe different credentials are required), go to the next step.
// ///////////////////////////////////////////////////////////////////////////////////////////////
- if (await authorize()) return next();
+ if (await authorize()) {
+ if(requireAdmin) {
+ res.status(200).send('Authorized')
+ return
+ }
+ return next();
+ }
// ///////////////////////////////////////////////////////////////////////////////////////////////
// Step 3: Authenticate the user. (Or, if already logged in, reauthenticate with different
@@ -163,7 +169,7 @@ const checkAccess = async (req:any, res:any, next: Function) => {
if (await aCallFirst0('authnFailure', {req, res})) return;
if (await aCallFirst0('authFailure', {req, res, next})) return;
// No plugin handled the authentication failure. Fall back to basic authentication.
- res.header('WWW-Authenticate', 'Basic realm="Protected Area"');
+ //res.header('WWW-Authenticate', 'Basic realm="Protected Area"');
// Delay the error response for 1s to slow down brute force attacks.
await new Promise((resolve) => setTimeout(resolve, exports.authnFailureDelayMs));
res.status(401).send('Authentication Required');
@@ -188,7 +194,13 @@ const checkAccess = async (req:any, res:any, next: Function) => {
// a login page).
// ///////////////////////////////////////////////////////////////////////////////////////////////
- if (await authorize()) return next();
+ const auth = await authorize()
+ if (auth && !requireAdmin) return next();
+ if(auth && requireAdmin) {
+ res.status(200).send('Authorized')
+ return
+ }
+
if (await aCallFirst0('authzFailure', {req, res})) return;
if (await aCallFirst0('authFailure', {req, res, next})) return;
// No plugin handled the authorization failure.
diff --git a/src/node/types/PadSearchQuery.ts b/src/node/types/PadSearchQuery.ts
new file mode 100644
index 000000000..aaef233e6
--- /dev/null
+++ b/src/node/types/PadSearchQuery.ts
@@ -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
+}
diff --git a/src/node/utils/UpdateCheck.ts b/src/node/utils/UpdateCheck.ts
index 193a40a98..f4c10182a 100644
--- a/src/node/utils/UpdateCheck.ts
+++ b/src/node/utils/UpdateCheck.ts
@@ -42,7 +42,7 @@ exports.getLatestVersion = () => {
return infos?.latestVersion;
};
-exports.needsUpdate = async (cb: Function) => {
+exports.needsUpdate = async (cb?: Function) => {
await loadEtherpadInformations()
.then((info:Infos) => {
if (semver.gt(info.latestVersion, settings.getEpVersion())) {
diff --git a/src/static/js/admin/jquery.autosize.js b/src/static/js/admin/jquery.autosize.js
deleted file mode 100644
index a94ef3cde..000000000
--- a/src/static/js/admin/jquery.autosize.js
+++ /dev/null
@@ -1,180 +0,0 @@
-// Autosize 1.13 - jQuery plugin for textareas
-// (c) 2012 Jack Moore - jacklmoore.com
-// license: www.opensource.org/licenses/mit-license.php
-
-(function ($) {
- var
- defaults = {
- className: 'autosizejs',
- append: "",
- callback: false
- },
- hidden = 'hidden',
- borderBox = 'border-box',
- lineHeight = 'lineHeight',
- copy = '',
- // line-height is omitted because IE7/IE8 doesn't return the correct value.
- copyStyle = [
- 'fontFamily',
- 'fontSize',
- 'fontWeight',
- 'fontStyle',
- 'letterSpacing',
- 'textTransform',
- 'wordSpacing',
- 'textIndent'
- ],
- oninput = 'oninput',
- onpropertychange = 'onpropertychange',
- test = $(copy)[0];
-
- // For testing support in old FireFox
- test.setAttribute(oninput, "return");
-
- if ($.isFunction(test[oninput]) || onpropertychange in test) {
-
- // test that line-height can be accurately copied to avoid
- // incorrect value reporting in old IE and old Opera
- $(test).css(lineHeight, '99px');
- if ($(test).css(lineHeight) === '99px') {
- copyStyle.push(lineHeight);
- }
-
- $.fn.autosize = function (options) {
- options = $.extend({}, defaults, options || {});
-
- return this.each(function () {
- var
- ta = this,
- $ta = $(ta),
- mirror,
- minHeight = $ta.height(),
- maxHeight = parseInt($ta.css('maxHeight'), 10),
- active,
- i = copyStyle.length,
- resize,
- boxOffset = 0,
- value = ta.value,
- callback = $.isFunction(options.callback);
-
- if ($ta.css('box-sizing') === borderBox || $ta.css('-moz-box-sizing') === borderBox || $ta.css('-webkit-box-sizing') === borderBox){
- boxOffset = $ta.outerHeight() - $ta.height();
- }
-
- if ($ta.data('mirror') || $ta.data('ismirror')) {
- // if autosize has already been applied, exit.
- // if autosize is being applied to a mirror element, exit.
- return;
- } else {
- mirror = $(copy).data('ismirror', true).addClass(options.className)[0];
-
- resize = $ta.css('resize') === 'none' ? 'none' : 'horizontal';
-
- $ta.data('mirror', $(mirror)).css({
- overflow: hidden,
- overflowY: hidden,
- wordWrap: 'break-word',
- resize: resize
- });
- }
-
- // Opera returns '-1px' when max-height is set to 'none'.
- maxHeight = maxHeight && maxHeight > 0 ? maxHeight : 9e4;
-
- // Using mainly bare JS in this function because it is going
- // to fire very often while typing, and needs to very efficient.
- function adjust() {
- var height, overflow, original;
-
- // the active flag keeps IE from tripping all over itself. Otherwise
- // actions in the adjust function will cause IE to call adjust again.
- if (!active) {
- active = true;
- mirror.value = ta.value + options.append;
- mirror.style.overflowY = ta.style.overflowY;
- original = parseInt(ta.style.height,10);
-
- // Update the width in case the original textarea width has changed
- mirror.style.width = $ta.css('width');
-
- // Needed for IE to reliably return the correct scrollHeight
- mirror.scrollTop = 0;
-
- // Set a very high value for scrollTop to be sure the
- // mirror is scrolled all the way to the bottom.
- mirror.scrollTop = 9e4;
-
- height = mirror.scrollTop;
- overflow = hidden;
- if (height > maxHeight) {
- height = maxHeight;
- overflow = 'scroll';
- } else if (height < minHeight) {
- height = minHeight;
- }
- height += boxOffset;
- ta.style.overflowY = overflow;
-
- if (original !== height) {
- ta.style.height = height + 'px';
- if (callback) {
- options.callback.call(ta);
- }
- }
-
- // This small timeout gives IE a chance to draw it's scrollbar
- // before adjust can be run again (prevents an infinite loop).
- setTimeout(function () {
- active = false;
- }, 1);
- }
- }
-
- // mirror is a duplicate textarea located off-screen that
- // is automatically updated to contain the same text as the
- // original textarea. mirror always has a height of 0.
- // This gives a cross-browser supported way getting the actual
- // height of the text, through the scrollTop property.
- while (i--) {
- mirror.style[copyStyle[i]] = $ta.css(copyStyle[i]);
- }
-
- $('body').append(mirror);
-
- if (onpropertychange in ta) {
- if (oninput in ta) {
- // Detects IE9. IE9 does not fire onpropertychange or oninput for deletions,
- // so binding to onkeyup to catch most of those occassions. There is no way that I
- // know of to detect something like 'cut' in IE9.
- ta[oninput] = ta.onkeyup = adjust;
- } else {
- // IE7 / IE8
- ta[onpropertychange] = adjust;
- }
- } else {
- // Modern Browsers
- ta[oninput] = adjust;
-
- // The textarea overflow is now hidden. But Chrome doesn't reflow the text after the scrollbars are removed.
- // This is a hack to get Chrome to reflow it's text.
- ta.value = '';
- ta.value = value;
- }
-
- $(window).resize(adjust);
-
- // Allow for manual triggering if needed.
- $ta.on('autosize', adjust);
-
- // Call adjust in case the textarea already contains text.
- adjust();
- });
- };
- } else {
- // Makes no changes for older browsers (FireFox3- and Safari4-)
- $.fn.autosize = function (callback) {
- return this;
- };
- }
-
-}(jQuery));
\ No newline at end of file
diff --git a/src/static/js/admin/minify.json.js b/src/static/js/admin/minify.json.js
deleted file mode 100644
index 4edbd6e1d..000000000
--- a/src/static/js/admin/minify.json.js
+++ /dev/null
@@ -1,61 +0,0 @@
-/*! JSON.minify()
- v0.1 (c) Kyle Simpson
- MIT License
-*/
-
-(function(global){
- if (typeof global.JSON == "undefined" || !global.JSON) {
- global.JSON = {};
- }
-
- global.JSON.minify = function(json) {
-
- var 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("");
- };
-})(this);
diff --git a/src/static/js/admin/plugins.js b/src/static/js/admin/plugins.js
deleted file mode 100644
index e51e1102d..000000000
--- a/src/static/js/admin/plugins.js
+++ /dev/null
@@ -1,273 +0,0 @@
-'use strict';
-
-/* global socketio */
-
-$(document).ready(() => {
- const socket = socketio.connect('..', '/pluginfw/installer');
- socket.on('disconnect', (reason) => {
- // The socket.io client will automatically try to reconnect for all reasons other than "io
- // server disconnect".
- if (reason === 'io server disconnect') socket.connect();
- });
-
- const search = (searchTerm, limit) => {
- if (search.searchTerm !== searchTerm) {
- search.offset = 0;
- search.results = [];
- search.end = false;
- }
- limit = limit ? limit : search.limit;
- search.searchTerm = searchTerm;
- socket.emit('search', {
- searchTerm,
- offset: search.offset,
- limit,
- sortBy: search.sortBy,
- sortDir: search.sortDir,
- });
- search.offset += limit;
-
- $('#search-progress').show();
- search.messages.show('fetching');
- search.searching = true;
- };
- search.searching = false;
- search.offset = 0;
- search.limit = 999;
- search.results = [];
- search.sortBy = 'name';
- search.sortDir = /* DESC?*/true;
- search.end = true;// have we received all results already?
- search.messages = {
- show: (msg) => {
- // $('.search-results .messages').show()
- $(`.search-results .messages .${msg}`).show();
- $(`.search-results .messages .${msg} *`).show();
- },
- hide: (msg) => {
- $('.search-results .messages').hide();
- $(`.search-results .messages .${msg}`).hide();
- $(`.search-results .messages .${msg} *`).hide();
- },
- };
-
- const installed = {
- progress: {
- show: (plugin, msg) => {
- $(`.installed-results .${plugin} .progress`).show();
- $(`.installed-results .${plugin} .progress .message`).text(msg);
- if ($(window).scrollTop() > $(`.${plugin}`).offset().top) {
- $(window).scrollTop($(`.${plugin}`).offset().top - 100);
- }
- },
- hide: (plugin) => {
- $(`.installed-results .${plugin} .progress`).hide();
- $(`.installed-results .${plugin} .progress .message`).text('');
- },
- },
- messages: {
- show: (msg) => {
- $('.installed-results .messages').show();
- $(`.installed-results .messages .${msg}`).show();
- },
- hide: (msg) => {
- $('.installed-results .messages').hide();
- $(`.installed-results .messages .${msg}`).hide();
- },
- },
- list: [],
- };
-
- const displayPluginList = (plugins, container, template) => {
- plugins.forEach((plugin) => {
- const row = template.clone();
-
- for (const attr in plugin) {
- if (attr === 'name') { // Hack to rewrite URLS into name
- const link = $('')
- .attr('href', `https://npmjs.org/package/${plugin.name}`)
- .attr('plugin', 'Plugin details')
- .attr('rel', 'noopener noreferrer')
- .attr('target', '_blank')
- .text(plugin.name.substr(3));
- row.find('.name').append(link);
- } else {
- row.find(`.${attr}`).text(plugin[attr]);
- }
- }
- row.find('.version').text(plugin.version);
- row.addClass(plugin.name);
- row.data('plugin', plugin.name);
- container.append(row);
- });
- updateHandlers();
- };
-
- const sortPluginList = (plugins, property, /* ASC?*/dir) => plugins.sort((a, b) => {
- if (a[property] < b[property]) return dir ? -1 : 1;
- if (a[property] > b[property]) return dir ? 1 : -1;
- // a must be equal to b
- return 0;
- });
-
- const updateHandlers = () => {
- // Search
- $('#search-query').off('keyup').on('keyup', () => {
- search($('#search-query').val());
- });
-
- // Prevent form submit
- $('#search-query').parent().on('submit', () => false);
-
- // update & install
- $('.do-install, .do-update').off('click').on('click', function (e) {
- const $row = $(e.target).closest('tr');
- const plugin = $row.data('plugin');
- if ($(this).hasClass('do-install')) {
- $row.remove().appendTo('#installed-plugins');
- installed.progress.show(plugin, 'Installing');
- } else {
- installed.progress.show(plugin, 'Updating');
- }
- socket.emit('install', plugin);
- installed.messages.hide('nothing-installed');
- });
-
- // uninstall
- $('.do-uninstall').off('click').on('click', (e) => {
- const $row = $(e.target).closest('tr');
- const pluginName = $row.data('plugin');
- socket.emit('uninstall', pluginName);
- installed.progress.show(pluginName, 'Uninstalling');
- installed.list = installed.list.filter((plugin) => plugin.name !== pluginName);
- });
-
- // Sort
- $('.sort.up').off('click').on('click', function () {
- search.sortBy = $(this).attr('data-label').toLowerCase();
- search.sortDir = false;
- search.offset = 0;
- search(search.searchTerm, search.results.length);
- search.results = [];
- });
- $('.sort.down, .sort.none').off('click').on('click', function () {
- search.sortBy = $(this).attr('data-label').toLowerCase();
- search.sortDir = true;
- search.offset = 0;
- search(search.searchTerm, search.results.length);
- search.results = [];
- });
- };
-
- socket.on('results:search', (data) => {
- if (!data.results.length) search.end = true;
- if (data.query.offset === 0) search.results = [];
- search.messages.hide('nothing-found');
- search.messages.hide('fetching');
- $('#search-query').prop('disabled', false);
-
- console.log('got search results', data);
-
- // add to results
- search.results = search.results.concat(data.results);
-
- // Update sorting head
- $('.sort')
- .removeClass('up down')
- .addClass('none');
- $(`.search-results thead th[data-label=${data.query.sortBy}]`)
- .removeClass('none')
- .addClass(data.query.sortDir ? 'up' : 'down');
-
- // re-render search results
- const searchWidget = $('.search-results');
- searchWidget.find('.results *').remove();
- if (search.results.length > 0) {
- displayPluginList(
- search.results, searchWidget.find('.results'), searchWidget.find('.template tr'));
- } else {
- search.messages.show('nothing-found');
- }
- search.messages.hide('fetching');
- $('#search-progress').hide();
- search.searching = false;
- });
-
- socket.on('results:installed', (data) => {
- installed.messages.hide('fetching');
- installed.messages.hide('nothing-installed');
-
- installed.list = data.installed;
- sortPluginList(installed.list, 'name', /* ASC?*/true);
-
- // filter out epl
- installed.list = installed.list.filter((plugin) => plugin.name !== 'ep_etherpad-lite');
-
- // remove all installed plugins (leave plugins that are still being installed)
- installed.list.forEach((plugin) => {
- $(`#installed-plugins .${plugin.name}`).remove();
- });
-
- if (installed.list.length > 0) {
- displayPluginList(installed.list, $('#installed-plugins'), $('#installed-plugin-template'));
- socket.emit('checkUpdates');
- } else {
- installed.messages.show('nothing-installed');
- }
- });
-
- socket.on('results:updatable', (data) => {
- data.updatable.forEach((pluginName) => {
- const actions = $(`#installed-plugins > tr.${pluginName} .actions`);
- actions.find('.do-update').remove();
- actions.append(
- $('').addClass('do-update').attr('type', 'button').attr('value', 'Update'));
- });
- updateHandlers();
- });
-
- socket.on('finished:install', (data) => {
- if (data.error) {
- if (data.code === 'EPEERINVALID') {
- alert("This plugin requires that you update Etherpad so it can operate in it's true glory");
- }
- alert(`An error occurred while installing ${data.plugin} \n${data.error}`);
- $(`#installed-plugins .${data.plugin}`).remove();
- }
-
- socket.emit('getInstalled');
-
- // update search results
- search.offset = 0;
- search(search.searchTerm, search.results.length);
- search.results = [];
- });
-
- socket.on('finished:uninstall', (data) => {
- if (data.error) {
- alert(`An error occurred while uninstalling the ${data.plugin} \n${data.error}`);
- }
-
- // remove plugin from installed list
- $(`#installed-plugins .${data.plugin}`).remove();
-
- socket.emit('getInstalled');
-
- // update search results
- search.offset = 0;
- search(search.searchTerm, search.results.length);
- search.results = [];
- });
-
- socket.on('connect', () => {
- updateHandlers();
- socket.emit('getInstalled');
- search.searchTerm = null;
- search($('#search-query').val());
- });
-
- // check for updates every 5mins
- setInterval(() => {
- socket.emit('checkUpdates');
- }, 1000 * 60 * 5);
-});
diff --git a/src/static/js/admin/settings.js b/src/static/js/admin/settings.js
deleted file mode 100644
index 4123546e1..000000000
--- a/src/static/js/admin/settings.js
+++ /dev/null
@@ -1,69 +0,0 @@
-'use strict';
-
-$(document).ready(() => {
- const socket = window.socketio.connect('..', '/settings');
-
- socket.on('connect', () => {
- socket.emit('load');
- });
-
- socket.on('disconnect', (reason) => {
- // The socket.io client will automatically try to reconnect for all reasons other than "io
- // server disconnect".
- if (reason === 'io server disconnect') socket.connect();
- });
-
- socket.on('settings', (settings) => {
- /* Check whether the settings.json is authorized to be viewed */
- if (settings.results === 'NOT_ALLOWED') {
- $('.innerwrapper').hide();
- $('.innerwrapper-err').show();
- $('.err-message').html('Settings json is not authorized to be viewed in Admin page!!');
- return;
- }
-
- /* Check to make sure the JSON is clean before proceeding */
- if (isJSONClean(settings.results)) {
- $('.settings').append(settings.results);
- $('.settings').trigger('focus');
- $('.settings').autosize();
- } else {
- alert('Invalid JSON');
- }
- });
-
- /* When the admin clicks save Settings check the JSON then send the JSON back to the server */
- $('#saveSettings').on('click', () => {
- const editedSettings = $('.settings').val();
- if (isJSONClean(editedSettings)) {
- // JSON is clean so emit it to the server
- socket.emit('saveSettings', $('.settings').val());
- } else {
- alert('Invalid JSON');
- $('.settings').trigger('focus');
- }
- });
-
- /* Tell Etherpad Server to restart */
- $('#restartEtherpad').on('click', () => {
- socket.emit('restartServer');
- });
-
- socket.on('saveprogress', (progress) => {
- $('#response').show();
- $('#response').text(progress);
- $('#response').fadeOut('slow');
- });
-});
-
-
-const isJSONClean = (data) => {
- let cleanSettings = JSON.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
- }
-};
diff --git a/src/static/js/pluginfw/plugins.js b/src/static/js/pluginfw/plugins.js
index ef434d404..db55c1c30 100644
--- a/src/static/js/pluginfw/plugins.js
+++ b/src/static/js/pluginfw/plugins.js
@@ -9,7 +9,7 @@ const tsort = require('./tsort');
const pluginUtils = require('./shared');
const defs = require('./plugin_defs');
const {manager} = require('./installer');
-const settings = require("../../../node/utils/Settings");
+const settings = require('../../../node/utils/Settings');
const logger = log4js.getLogger('plugins');
@@ -28,10 +28,13 @@ exports.prefix = 'ep_';
exports.formatPlugins = () => Object.keys(defs.plugins).join(', ');
+exports.getPlugins = () => Object.keys(defs.plugins);
+
exports.formatParts = () => defs.parts.map((part) => part.full_name).join('\n');
-exports.formatHooks = (hookSetName, html) => {
- let hooks = new Map();
+exports.getParts = () => defs.parts.map((part) => part.full_name);
+
+const sortHooks = (hookSetName, hooks) => {
for (const [pluginName, def] of Object.entries(defs.plugins)) {
for (const part of def.parts) {
for (const [hookName, hookFnName] of Object.entries(part[hookSetName] || {})) {
@@ -49,6 +52,18 @@ exports.formatHooks = (hookSetName, html) => {
}
}
}
+};
+
+
+exports.getHooks = (hookSetName) => {
+ const hooks = new Map();
+ sortHooks(hookSetName, hooks);
+ return hooks;
+};
+
+exports.formatHooks = (hookSetName, html) => {
+ let hooks = new Map();
+ sortHooks(hookSetName, hooks);
const lines = [];
const sortStringKeys = (a, b) => String(a[0]).localeCompare(b[0]);
if (html) lines.push('');
@@ -107,8 +122,8 @@ exports.update = async () => {
};
exports.getPackages = async () => {
- let plugins = manager.list()
- let newDependencies = {}
+ const plugins = manager.list();
+ const newDependencies = {};
for (const plugin of plugins) {
if (!plugin.name.startsWith(exports.prefix)) {
@@ -116,7 +131,7 @@ exports.getPackages = async () => {
}
plugin.realPath = await fs.realpath(plugin.location);
plugin.path = plugin.realPath;
- newDependencies[plugin.name] = plugin
+ newDependencies[plugin.name] = plugin;
}
newDependencies['ep_etherpad-lite'] = {
@@ -124,7 +139,7 @@ exports.getPackages = async () => {
version: settings.getEpVersion(),
path: path.join(settings.root, 'node_modules/ep_etherpad-lite'),
realPath: path.join(settings.root, 'src'),
- }
+ };
return newDependencies;
};
diff --git a/src/templates/admin/index.html b/src/templates/admin/index.html
deleted file mode 100644
index d4478153a..000000000
--- a/src/templates/admin/index.html
+++ /dev/null
@@ -1,28 +0,0 @@
-
-
-
- Admin Dashboard - Etherpad
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/templates/admin/plugins-info.html b/src/templates/admin/plugins-info.html
deleted file mode 100644
index 57e41f852..000000000
--- a/src/templates/admin/plugins-info.html
+++ /dev/null
@@ -1,47 +0,0 @@
-
-
-
- Plugin information - Etherpad
-
-
-
-
-
-
-
-
-
-
-
-
Etherpad version
-
Version number: <%= epVersion %>
-
Latest available version: <%= latestVersion %>
-
Git sha: <%= gitCommit %>
-
-
Installed plugins
- <%- installedPlugins %>
-
-
Installed parts
- <%- installedParts %>
-
-
Installed hooks
-
Server-side hooks
- <%- installedServerHooks %>
-
-
Client-side hooks
- <%- installedClientHooks %>
-
-
-
-
-
-
diff --git a/src/templates/admin/plugins.html b/src/templates/admin/plugins.html
deleted file mode 100644
index 278304faf..000000000
--- a/src/templates/admin/plugins.html
+++ /dev/null
@@ -1,121 +0,0 @@
-
-
-
- Plugin manager - Etherpad
-
-
-
-
-
-
-
-
-
-
-
-
-
- <% if (errors.length) { %>
-
- <% errors.forEach(function (item) { %>
-
<%= item.toString() %>
- <% }) %>
-
- <% } %>
-
-
-
-
-
Installed plugins
-
-
-
- Name |
- Description |
- Version |
- |
-
-
-
-
- |
- |
- |
-
-
- |
-
-
-
-
-
- |
- You haven't installed any plugins yet.
- Fetching installed plugins…
- | |
-
-
-
-
-
-
-
Available plugins
-
-
-
-
-
- Name |
- Description |
- Version |
- Last update |
- |
-
-
-
-
- |
- |
- |
- |
-
-
- |
-
-
-
-
-
- |
-
-
- No plugins found.
- Fetching…
-
- | |
-
-
-
-
-
-
-
-
-
diff --git a/src/templates/admin/settings.html b/src/templates/admin/settings.html
deleted file mode 100644
index ffa4172f7..000000000
--- a/src/templates/admin/settings.html
+++ /dev/null
@@ -1,58 +0,0 @@
-
-
-
- Settings - Etherpad
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- <% if (errors.length) { %>
-
- <% errors.forEach(function (item) { %>
-
<%= item.toString() %>
- <% }) %>
-
- <% } %>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/templates/javascript.html b/src/templates/javascript.html
index 42482f69d..c501af65c 100644
--- a/src/templates/javascript.html
+++ b/src/templates/javascript.html
@@ -34,24 +34,16 @@
require-kernel.js |
- plugins.js |
Apache-2.0-only |
- plugins.js |
- minify.json.js |
Expat |
- minify.json.js |
- settings.js |
Apache-2.0-only |
- settings.js |
- jquery.autosize.js |
Expat |
- jquery.autosize.js |