diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index f32093ac4..041f7c1a9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -30,8 +30,7 @@ If applicable, add screenshots to help explain your problem. - OS: [e.g., Ubuntu 20.04] - Node.js version (`node --version`): - npm version (`npm --version`): - - Is the server free of plugins: - - Are you using any abstraction IE docker? + - Is the server free of plugins: **Desktop (please complete the following information):** - OS: [e.g. iOS] diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 822bd8b74..d9f660907 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,7 +1,7 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/admin/public/ep_admin_authors/en.json b/admin/public/ep_admin_authors/en.json deleted file mode 100644 index f482d60a1..000000000 --- a/admin/public/ep_admin_authors/en.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "title": "Authors", - "search-placeholder": "Search by name or mapper", - "column.color": "Color", - "column.name": "Name", - "column.mapper": "Mapper", - "column.last-seen": "Last seen", - "column.author-id": "Author ID", - "column.actions": "Actions", - "show-erased": "Show erased authors", - "erase": "Erase", - "erase-disabled-tooltip": "Author erasure is disabled. Set gdprAuthorErasure.enabled = true in settings.json.", - "erased-stub": "(erased)", - "cap-warning": "Showing the first 1000 authors. Narrow your search to see more.", - "feature-disabled-banner": "Author erasure is disabled. Set \"gdprAuthorErasure\": {\"enabled\": true} in settings.json to enable.", - "no-results": "No authors match this search.", - "confirm-dialog-title": "Confirm author erasure", - "confirm-dialog-description": "Review the impact of erasing this author and confirm or cancel.", - "confirm-preview-title": "Erase author {{name}}", - "confirm-preview-counters": "Will clear {{tokenMappings}} token mappings, {{externalMappings}} mapper bindings, and {{chatMessages}} chat messages across {{affectedPads}} pads.", - "confirm-irreversible": "This cannot be undone.", - "cancel": "Cancel", - "continue": "Continue", - "loading-preview": "Loading preview…", - "erasing": "Erasing…", - "erase-success-toast": "Author {{authorID}} erased.", - "erase-error-toast": "Erase failed: {{error}}", - "no-mappers": "—", - "never-seen": "—", - "prev-page": "Previous Page", - "next-page": "Next Page", - "page-counter": "{{current}} out of {{total}}" -} diff --git a/admin/scripts/__tests__/merge-openapi.test.mjs b/admin/scripts/__tests__/merge-openapi.test.mjs deleted file mode 100644 index 7fb454c11..000000000 --- a/admin/scripts/__tests__/merge-openapi.test.mjs +++ /dev/null @@ -1,74 +0,0 @@ -import {test} from 'node:test'; -import {strict as assert} from 'node:assert'; -import {mergeOpenAPI} from '../merge-openapi.mjs'; - -const minimal = (overrides = {}) => ({ - openapi: '3.0.2', - info: {title: 'X', version: '0.0.0'}, - paths: {}, - components: {schemas: {}, securitySchemes: {}}, - ...overrides, -}); - -test('unions paths from both docs', () => { - const pub = minimal({paths: {'/createGroup': {post: {operationId: 'createGroup'}}}}); - const adm = minimal({paths: {'/admin-auth/': {post: {operationId: 'verifyAdminAccess'}}}}); - const out = mergeOpenAPI(pub, adm); - assert.deepEqual(Object.keys(out.paths).sort(), ['/admin-auth/', '/createGroup']); -}); - -test('throws on path collision', () => { - const pub = minimal({paths: {'/x': {get: {}}}}); - const adm = minimal({paths: {'/x': {post: {}}}}); - assert.throws(() => mergeOpenAPI(pub, adm), /path collision/i); -}); - -test('unions components.schemas', () => { - const pub = minimal({components: {schemas: {A: {}}, securitySchemes: {}}}); - const adm = minimal({components: {schemas: {B: {}}, securitySchemes: {}}}); - const out = mergeOpenAPI(pub, adm); - assert.deepEqual(Object.keys(out.components.schemas).sort(), ['A', 'B']); -}); - -test('throws on schema name collision', () => { - const pub = minimal({components: {schemas: {Dup: {}}, securitySchemes: {}}}); - const adm = minimal({components: {schemas: {Dup: {}}, securitySchemes: {}}}); - assert.throws(() => mergeOpenAPI(pub, adm), /schema collision/i); -}); - -test('unions securitySchemes', () => { - const pub = minimal({components: {schemas: {}, securitySchemes: {apiKey: {}}}}); - const adm = minimal({components: {schemas: {}, securitySchemes: {basicAuth: {}}}}); - const out = mergeOpenAPI(pub, adm); - assert.deepEqual( - Object.keys(out.components.securitySchemes).sort(), - ['apiKey', 'basicAuth'], - ); -}); - -test('preserves public root security; admin per-operation security survives', () => { - const pub = minimal({security: [{apiKey: []}]}); - const adm = minimal({ - paths: { - '/admin-auth/': { - post: { - security: [{basicAuth: []}, {}], - }, - }, - }, - }); - const out = mergeOpenAPI(pub, adm); - assert.deepEqual(out.security, [{apiKey: []}]); - assert.deepEqual( - out.paths['/admin-auth/'].post.security, - [{basicAuth: []}, {}], - ); -}); - -test('public info wins on conflict', () => { - const pub = minimal({info: {title: 'Public', version: '1.0'}}); - const adm = minimal({info: {title: 'Admin', version: '2.0'}}); - const out = mergeOpenAPI(pub, adm); - assert.equal(out.info.title, 'Public'); - assert.equal(out.info.version, '1.0'); -}); diff --git a/admin/scripts/dump-spec.ts b/admin/scripts/dump-spec.ts deleted file mode 100644 index 6c229e802..000000000 --- a/admin/scripts/dump-spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -// admin/scripts/dump-spec.ts -// -// Imports the public + admin OpenAPI spec builders from the etherpad -// source, merges them into one document, and writes JSON to argv[2]. -// Invoked by admin/scripts/gen-api.mjs via `tsx`. -// -// Why a file argument instead of stdout: importing openapi*.ts triggers -// Settings init, which configures log4js to write INFO/WARN lines to -// stdout. Capturing stdout would mix logs with JSON. - -import {writeFileSync} from 'node:fs'; -import path from 'node:path'; -import {fileURLToPath, pathToFileURL} from 'node:url'; -// @ts-expect-error — sibling .mjs has no .d.ts; tsx resolves it at runtime. -import {mergeOpenAPI} from './merge-openapi.mjs'; - -const outFile = process.argv[2]; -if (!outFile) { - process.stderr.write('Usage: tsx scripts/dump-spec.ts \n'); - process.exit(2); -} - -const here = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(here, '..', '..'); - -const apiHandlerPath = path.join(repoRoot, 'src', 'node', 'handler', 'APIHandler.ts'); -const openapiPath = path.join(repoRoot, 'src', 'node', 'hooks', 'express', 'openapi.ts'); -const openapiAdminPath = path.join( - repoRoot, 'src', 'node', 'hooks', 'express', 'openapi-admin.ts', -); - -type ApiHandlerModule = {latestApiVersion: string}; -type OpenApiModule = { - generateDefinitionForVersion: (version: string, style?: string) => unknown; - APIPathStyle: {FLAT: string; REST: string}; -}; -type OpenApiAdminModule = { - generateAdminDefinition: () => unknown; -}; - -const apiHandlerMod = await import(pathToFileURL(apiHandlerPath).href); -const openapiMod = await import(pathToFileURL(openapiPath).href); -const openapiAdminMod = await import(pathToFileURL(openapiAdminPath).href); - -const apiHandler = (apiHandlerMod.default ?? apiHandlerMod) as ApiHandlerModule; -const openapi = (openapiMod.default ?? openapiMod) as OpenApiModule; -const openapiAdmin = (openapiAdminMod.default ?? openapiAdminMod) as OpenApiAdminModule; - -const publicSpec = openapi.generateDefinitionForVersion( - apiHandler.latestApiVersion, - openapi.APIPathStyle.FLAT, -); -const adminSpec = openapiAdmin.generateAdminDefinition(); - -const merged = mergeOpenAPI(publicSpec, adminSpec); - -writeFileSync(path.resolve(outFile), JSON.stringify(merged, null, 2), 'utf8'); diff --git a/admin/scripts/gen-api.mjs b/admin/scripts/gen-api.mjs deleted file mode 100644 index d96383e25..000000000 --- a/admin/scripts/gen-api.mjs +++ /dev/null @@ -1,78 +0,0 @@ -// admin/scripts/gen-api.mjs -// -// Regenerates admin/src/api/schema.d.ts from the live OpenAPI spec exported -// by src/node/hooks/express/openapi.ts. Run via `pnpm --filter admin gen:api`. - -import { spawnSync } from 'node:child_process'; -import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const here = path.dirname(fileURLToPath(import.meta.url)); -const adminRoot = path.resolve(here, '..'); -const outFile = path.join(adminRoot, 'src', 'api', 'schema.d.ts'); - -const tmpDir = mkdtempSync(path.join(tmpdir(), 'etherpad-openapi-')); -const specPath = path.join(tmpDir, 'spec.json'); - -// On Windows pnpm resolves to pnpm.cmd, which spawnSync can only find via a -// shell. Use shell on Windows only to avoid Node's DEP0190 warning elsewhere. -// Every argument here is fixed (no user input) so the shell:true variant is -// not an injection risk. -const spawnOpts = { - cwd: adminRoot, - stdio: 'inherit', - shell: process.platform === 'win32', -}; - -try { - const dump = spawnSync( - 'pnpm', - ['exec', 'tsx', 'scripts/dump-spec.ts', specPath], - spawnOpts, - ); - if (dump.status !== 0) { - console.error(`dump-spec.ts failed with exit code ${dump.status}`); - process.exit(dump.status ?? 1); - } - - const gen = spawnSync( - 'pnpm', - ['exec', 'openapi-typescript', specPath, '-o', outFile], - spawnOpts, - ); - if (gen.status !== 0) { - console.error(`openapi-typescript failed with exit code ${gen.status}`); - process.exit(gen.status ?? 1); - } - - const header = - `// GENERATED — do not edit. Run \`pnpm --filter admin gen:api\` to regenerate.\n` + - `// Source: src/node/hooks/express/openapi.ts (#7638)\n\n`; - const body = readFileSync(outFile, 'utf8'); - writeFileSync(outFile, header + body, 'utf8'); - - // Emit a runtime-side version constant so client.ts can build the right - // baseUrl. Generated paths are unprefixed (e.g. "/createGroup"), but the - // backend mounts the FLAT-style spec under /api//. - const spec = JSON.parse(readFileSync(specPath, 'utf8')); - const apiVersion = spec?.info?.version; - if (typeof apiVersion !== 'string' || apiVersion.length === 0) { - console.error('OpenAPI spec is missing info.version; cannot emit version.ts'); - process.exit(1); - } - const versionFile = path.join(adminRoot, 'src', 'api', 'version.ts'); - writeFileSync( - versionFile, - header + - `export const LATEST_API_VERSION = ${JSON.stringify(apiVersion)};\n` + - `export const API_BASE_URL = \`/api/\${LATEST_API_VERSION}\`;\n`, - 'utf8', - ); - - console.log(`Wrote ${path.relative(process.cwd(), outFile)}`); - console.log(`Wrote ${path.relative(process.cwd(), versionFile)}`); -} finally { - rmSync(tmpDir, { recursive: true, force: true }); -} diff --git a/admin/scripts/merge-openapi.mjs b/admin/scripts/merge-openapi.mjs deleted file mode 100644 index ff78576c7..000000000 --- a/admin/scripts/merge-openapi.mjs +++ /dev/null @@ -1,56 +0,0 @@ -// admin/scripts/merge-openapi.mjs -// -// Deep-merges the public-API OpenAPI document with the admin OpenAPI -// document into a single document for openapi-typescript to consume. -// -// Rules: -// - paths: union by key; collision throws -// - components.{schemas,parameters,responses,securitySchemes}: union by name; collision throws -// - root info, servers, security: public wins (admin's are ignored at the root) -// - per-operation security on admin paths is preserved untouched - -const unionMap = (label, a = {}, b = {}) => { - const out = {...a}; - for (const [k, v] of Object.entries(b)) { - if (k in out) { - throw new Error(`${label} on key "${k}"`); - } - out[k] = v; - } - return out; -}; - -export const mergeOpenAPI = (publicDoc, adminDoc) => { - if (!publicDoc || !adminDoc) { - throw new Error('mergeOpenAPI requires both publicDoc and adminDoc'); - } - return { - openapi: publicDoc.openapi || adminDoc.openapi, - info: publicDoc.info, - ...(publicDoc.servers ? {servers: publicDoc.servers} : {}), - ...(publicDoc.security ? {security: publicDoc.security} : {}), - paths: unionMap('path collision', publicDoc.paths, adminDoc.paths), - components: { - schemas: unionMap( - 'schema collision', - publicDoc.components?.schemas, - adminDoc.components?.schemas, - ), - parameters: unionMap( - 'parameter collision', - publicDoc.components?.parameters, - adminDoc.components?.parameters, - ), - responses: unionMap( - 'response collision', - publicDoc.components?.responses, - adminDoc.components?.responses, - ), - securitySchemes: unionMap( - 'securityScheme collision', - publicDoc.components?.securitySchemes, - adminDoc.components?.securitySchemes, - ), - }, - }; -}; diff --git a/admin/src/App.css b/admin/src/App.css index c0e02cd0e..e69de29bb 100644 --- a/admin/src/App.css +++ b/admin/src/App.css @@ -1,347 +0,0 @@ -/* Raw textarea (kept dark to signal "this is code") */ -textarea.settings { - font-family: "Fira Code", "Cascadia Code", "Source Code Pro", monospace; - font-size: 14px; - white-space: pre; - overflow-wrap: normal; - overflow-x: auto; - width: 100%; - height: 500px; - padding: 15px; - background-color: #1e1e1e; - color: #d4d4d4; - line-height: 1.5; - border: 1px solid #333; - resize: vertical; -} -textarea.settings:focus { - outline: 2px solid #007acc; - outline-offset: -1px; -} - -.settings-button-bar { - display: flex; - flex-shrink: 0; - gap: 10px; - margin-top: 15px; -} - -.settings-links { - display: flex; - gap: 20px; - margin-top: 20px; - padding-top: 20px; - border-top: 1px solid #ddd; -} - -/* --- env-var banner --- */ -/* Shown only when settings.json contains ${VAR} placeholders. The - typical reader is a Docker/K8s operator who has just been surprised - by env-var substitution semantics, so the copy must explain rather - than warn — visual weight matches a note, not an error. */ -.settings-envvar-banner { - display: flex; - align-items: flex-start; - gap: 12px; - padding: 12px 16px; - margin-bottom: 16px; - background: #f5f7ff; - border: 1px solid #c7d2fe; - border-radius: 6px; - color: #1e293b; -} -.settings-envvar-banner svg { - flex-shrink: 0; - color: #4f46e5; - margin-top: 2px; -} -.settings-envvar-banner strong { - display: block; - margin-bottom: 4px; -} -.settings-envvar-banner p { - margin: 0; - font-size: 13px; - line-height: 1.5; -} - -/* --- mode toggle --- */ -.settings-mode-toggle { - display: inline-flex; - flex-shrink: 0; - border: 1px solid #ccc; - border-radius: 6px; - overflow: hidden; - margin-bottom: 16px; - background: #fff; -} -.settings-mode-toggle button { - padding: 6px 14px; - border: 0; - background: transparent; - color: #555; - font: inherit; - cursor: pointer; -} -.settings-mode-toggle button.active { - background: var(--etherpad-color, #0f775b); - color: #fff; -} - -/* --- form (light, two-column) --- */ -.settings-form { - font-family: inherit; - font-size: 14px; - color: #333; -} - -.settings-section { - background: #fff; - border: 1px solid #ddd; - border-radius: 6px; - margin-bottom: 18px; - overflow: hidden; -} -.settings-section-header { - padding: 14px 18px; - border-bottom: 1px solid #eee; - background: #fafafa; -} -.settings-section-header h2 { - margin: 0; - font-size: 15px; - font-weight: 600; - color: #222; - letter-spacing: 0.01em; - text-transform: uppercase; -} -.settings-section-header p { - margin: 4px 0 0; - font-size: 13px; - color: #666; - white-space: pre-wrap; -} -.settings-section-body { - padding: 4px 0; -} - -/* Two-column row: label | control, with help below spanning column 2. - * Single-column on narrow widths. */ -.settings-row { - display: grid; - grid-template-columns: minmax(180px, 220px) minmax(0, 1fr); - gap: 6px 18px; - padding: 10px 18px; - align-items: center; - border-top: 1px solid #f4f4f4; -} -.settings-row:first-child { - border-top: 0; -} -.settings-row-label { - font-weight: 600; - color: #333; - word-break: break-word; -} -.settings-row-control { - min-width: 0; -} -.settings-row-help { - grid-column: 2; - margin: 2px 0 0; - font-size: 12.5px; - color: #666; - white-space: pre-wrap; - line-height: 1.4; -} - -@media (max-width: 600px) { - .settings-row { - grid-template-columns: 1fr; - } - .settings-row-help { - grid-column: 1; - } -} - -/* --- nested subsections (objects/arrays inside a section) --- */ -.settings-subsection { - grid-column: 1 / -1; - margin: 8px 18px; - border-left: 3px solid #e2e2e2; - padding-left: 14px; -} -.settings-subsection-header { - display: flex; - flex-direction: column; - gap: 2px; - padding: 8px 0; -} -.settings-subsection-title { - font-weight: 600; - color: #444; - font-size: 13.5px; - text-transform: uppercase; - letter-spacing: 0.01em; -} -.settings-subsection-help { - color: #777; - font-size: 12.5px; - white-space: pre-wrap; -} -.settings-subsection-body .settings-row { - padding-left: 0; - padding-right: 0; -} - -/* --- leaf widgets (light) --- */ -.settings-widget-string, -.settings-widget-number { - width: 100%; - background: #fff; - color: #222; - border: 1px solid #ccc; - border-radius: 4px; - padding: 6px 10px; - font-family: inherit; - font-size: inherit; -} -.settings-widget-string:focus, -.settings-widget-number:focus { - outline: none; - border-color: var(--etherpad-color, #0f775b); - box-shadow: 0 0 0 3px rgba(15, 119, 91, 0.15); -} -.settings-widget-number.invalid { - border-color: #ce5050; -} -.settings-widget-null { - display: inline-block; - padding: 2px 8px; - border-radius: 10px; - background: #f0f0f0; - color: #888; - font-style: italic; - font-size: 12.5px; -} -.settings-widget-env { - display: inline-flex; - align-items: center; - gap: 6px; - background: #f4f8ff; - color: #335; - border: 1px dashed #88a; - border-radius: 12px; - padding: 2px 10px; - font-size: 13px; -} -.settings-widget-env-icon { - font-style: normal; - color: #557; -} -.settings-widget-env-name { - font-family: "Fira Code", monospace; - font-weight: 600; -} -.settings-widget-env-default-label { - color: #557; - font-size: 12px; - text-transform: lowercase; -} -.settings-widget-env-default-input { - background: white; - border: 1px solid #ccd; - border-radius: 6px; - padding: 1px 6px; - font-family: "Fira Code", monospace; - font-size: 13px; - color: #804; - min-width: 80px; - max-width: 240px; - width: auto; -} -.settings-widget-env-default-input:focus { - outline: 2px solid var(--accent, #2b8a3e); - outline-offset: 1px; -} -.settings-widget-env-runtime { - display: inline-flex; - align-items: center; - gap: 4px; - margin-left: 4px; - padding: 1px 8px; - border-radius: 10px; - background: #e6f4ea; - color: #1e5631; - font-family: "Fira Code", monospace; - font-size: 12px; -} -.settings-widget-env-runtime-redacted { - background: #ececec; - color: #555; -} -.settings-widget-env-runtime-arrow { - opacity: 0.6; -} -.settings-widget-env-runtime-label { - font-style: italic; - opacity: 0.7; -} -.settings-widget-env-runtime-value { - font-weight: 600; -} - -/* Radix switch (boolean) */ -.settings-widget-boolean { - appearance: none; - width: 36px; - height: 20px; - border-radius: 999px; - background: #ccc; - border: 0; - position: relative; - cursor: pointer; - transition: background 120ms ease; - padding: 0; -} -.settings-widget-boolean[data-state="checked"] { - background: var(--etherpad-color, #0f775b); -} -.settings-widget-boolean-thumb { - display: block; - width: 16px; - height: 16px; - background: #fff; - border-radius: 50%; - transform: translateX(2px); - transition: transform 120ms ease; - box-shadow: 0 1px 2px rgba(0,0,0,0.2); -} -.settings-widget-boolean[data-state="checked"] .settings-widget-boolean-thumb { - transform: translateX(18px); -} - -/* --- parse error --- */ -.settings-parse-error { - border: 1px solid #d99; - background: #fff5f5; - color: #842; - padding: 14px 18px; - border-radius: 6px; -} -.settings-parse-error-detail { - margin: 8px 0; - white-space: pre-wrap; - font-family: "Fira Code", monospace; - font-size: 12.5px; -} -.settings-parse-error button { - margin-top: 4px; - background: var(--etherpad-color, #0f775b); - color: #fff; - border: 0; - padding: 6px 14px; - border-radius: 4px; - cursor: pointer; - font: inherit; -} diff --git a/admin/src/App.tsx b/admin/src/App.tsx index d7395251b..ae23ab3d3 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -6,45 +6,45 @@ 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"; -import {Cable, Construction, Crown, NotepadText, Wrench, PhoneCall, LucideMenu, Bell, Users} from "lucide-react"; -import {UpdateBanner} from "./components/UpdateBanner"; +import {Cable, Construction, Crown, NotepadText, Wrench, PhoneCall, LucideMenu} from "lucide-react"; const WS_URL = import.meta.env.DEV ? 'http://localhost:9001' : '' - export const App = () => { const setSettings = useStore(state => state.setSettings); - const erasureEnabled = useStore(state => state.gdprAuthorErasureEnabled) const {t} = useTranslation() const navigate = useNavigate() const [sidebarOpen, setSidebarOpen] = useState(true) - const updateStatus = useStore(state => state.updateStatus) - const version = updateStatus?.currentVersion ?? null useEffect(() => { - fetch('/admin-auth/', {method: 'POST'}).then((value) => { - if (!value.ok) navigate('/login') - }).catch(() => navigate('/login')) + 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']}) - // When the server explicitly rejects us for not being admin, we must - // NOT reconnect on the subsequent `disconnect` event — otherwise the - // socket cycles forever (connect → admin_auth_error → server - // disconnect → SPA auto-reconnect → …). The flag is reset on each - // successful connect. - let authErrored = false; + 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', () => { - authErrored = false; useStore.getState().setSettingsSocket(settingSocket); useStore.getState().setShowLoading(false) settingSocket.emit('load'); @@ -52,52 +52,32 @@ export const App = () => { }); 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' && !authErrored) settingSocket.connect(); + if (reason === 'io server disconnect') { + settingSocket.connect(); + } }); - settingSocket.on('settings', (settings: any) => { - if (settings && typeof settings.flags === 'object' && settings.flags) { - useStore.getState().setGdprAuthorErasureEnabled( - !!settings.flags.gdprAuthorErasure); - } + 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') - useStore.getState().setResolved(null); return; } - if (isJSONClean(settings.results)) setSettings(settings.results); - else alert(t('admin_settings.invalid_json')); - // The resolved field is optional — old servers won't send it, - // and the SPA degrades to today's behaviour when it's null. - useStore.getState().setResolved(settings.resolved ?? null); - useStore.getState().setShowLoading(false); - }); - settingSocket.on('saveprogress', (status: string, payload?: {message?: string}) => { - const {setToastState} = useStore.getState(); - if (status === 'saved') { - setToastState({open: true, title: t('admin_settings.toast.saved'), success: true}); + /* Check to make sure the JSON is clean before proceeding */ + if (isJSONClean(settings.results)) { + setSettings(settings.results); } else { - const detail = payload?.message ?? ''; - setToastState({open: true, title: t('admin_settings.toast.save_failed') + (detail ? ` (${detail})` : ''), success: false}); + alert('Invalid JSON'); } + useStore.getState().setShowLoading(false); }); - // Backend emits this when the connecting socket does not have an - // admin session. Previously the server just dropped silently, so the - // SPA would sit on a loading screen with no clue. Surface it AND - // suppress the automatic reconnect — without this flag the SPA would - // immediately reconnect to a socket that will reject it again. - settingSocket.on('admin_auth_error', (payload?: {message?: string}) => { - authErrored = true; - const {setToastState} = useStore.getState(); - setToastState({ - open: true, - title: payload?.message || t('admin_settings.toast.auth_error'), - success: false, - }); - useStore.getState().setShowLoading(false); + settingSocket.on('saveprogress', (status) => { + console.log(status) }) return () => { @@ -106,109 +86,35 @@ export const App = () => { } }, []); - const closeOnMobile = () => { - if (window.innerWidth < 768) setSidebarOpen(false) - } - - return ( -
- -
-
-
- - {sidebarOpen && ( -
-
- Etherpad -
- )} -
- - - - {sidebarOpen && ( -
-
- - {version ? `v${version}` : 'Etherpad'} -
-
- )} -
-
- -
- - + return
+ +
+
+ + +

Etherpad

+
+
    { + if (window.innerWidth < 768) { + setSidebarOpen(false) + } + }}> +
  • +
  • +
  • +
  • +
  • Communication
  • +
- ) + +
+ +
+
} export default App diff --git a/admin/src/api/QueryProvider.tsx b/admin/src/api/QueryProvider.tsx deleted file mode 100644 index 54ee2d95c..000000000 --- a/admin/src/api/QueryProvider.tsx +++ /dev/null @@ -1,40 +0,0 @@ -// admin/src/api/QueryProvider.tsx -// -// TanStack Query provider for the admin UI. Devtools are loaded lazily and -// only in dev builds so they don't ship to production. - -import { lazy, Suspense, useState, type ReactNode } from 'react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; - -const Devtools = import.meta.env.DEV - ? lazy(() => - import('@tanstack/react-query-devtools').then((m) => ({ - default: m.ReactQueryDevtools, - })), - ) - : null; - -export const QueryProvider = ({ children }: { children: ReactNode }) => { - const [client] = useState( - () => - new QueryClient({ - defaultOptions: { - queries: { - staleTime: 30_000, - refetchOnWindowFocus: true, - }, - }, - }), - ); - - return ( - - {children} - {Devtools && ( - - - - )} - - ); -}; diff --git a/admin/src/api/__tests__/client.test.ts b/admin/src/api/__tests__/client.test.ts deleted file mode 100644 index 23230390b..000000000 --- a/admin/src/api/__tests__/client.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -// admin/src/api/__tests__/client.test.ts -// -// Smoke test that the OpenAPI client module loads and exposes the expected -// surface. Catches toolchain wiring regressions (missing peer deps, -// generator output that doesn't export `paths`, etc.). - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; - -test('client module exports public + admin clients and query hooks', async () => { - const mod = await import('../client.ts'); - assert.ok(mod.fetchClient, 'fetchClient export is present'); - assert.ok(mod.adminFetchClient, 'adminFetchClient export is present'); - assert.ok(mod.$api, '$api export is present'); - assert.ok(mod.$adminApi, '$adminApi export is present'); - assert.equal(typeof mod.fetchClient.GET, 'function', 'fetchClient.GET is a function'); - assert.equal(typeof mod.adminFetchClient.GET, 'function', 'adminFetchClient.GET is a function'); - assert.equal(typeof mod.$api.useQuery, 'function', '$api.useQuery is a function'); - assert.equal(typeof mod.$adminApi.useQuery, 'function', '$adminApi.useQuery is a function'); -}); diff --git a/admin/src/api/client.ts b/admin/src/api/client.ts deleted file mode 100644 index 39294c976..000000000 --- a/admin/src/api/client.ts +++ /dev/null @@ -1,30 +0,0 @@ -// admin/src/api/client.ts -// -// Typed HTTP clients and TanStack Query hooks derived from the generated -// OpenAPI schema. Regenerate the schema with `pnpm --filter admin gen:api`. -// -// The merged spec covers two surfaces with different baseUrls: -// -// - Public versioned API at /api// (paths like /createGroup) -// - Admin endpoints at root (paths like /admin-auth/) -// -// We narrow the generated `paths` interface by URL prefix and create one -// typed client per surface. TypeScript then rejects calling an admin path on -// the public client (or vice versa) at compile time — there is no shared -// client whose runtime baseUrl would silently target the wrong surface. - -import createClient from 'openapi-fetch'; -import createQueryHooks from 'openapi-react-query'; -import type { paths } from './schema'; -import { API_BASE_URL } from './version'; - -type AdminPath = Extract; -type PublicPath = Exclude; -type PublicPaths = Pick; -type AdminPaths = Pick; - -export const fetchClient = createClient({ baseUrl: API_BASE_URL }); -export const adminFetchClient = createClient({ baseUrl: '/' }); - -export const $api = createQueryHooks(fetchClient); -export const $adminApi = createQueryHooks(adminFetchClient); diff --git a/admin/src/components/ColorSwatch.tsx b/admin/src/components/ColorSwatch.tsx deleted file mode 100644 index f1e9b123f..000000000 --- a/admin/src/components/ColorSwatch.tsx +++ /dev/null @@ -1,37 +0,0 @@ -type Props = { - color: string | number | null; - size?: number; -}; - -// Resolves the colorId stored on globalAuthor records into a CSS color. -// AuthorManager stores either a string hex (legacy) or an integer index -// into the palette returned by getColorPalette() — we re-derive the -// palette here rather than fetch it because the order is stable and the -// admin already has many other small constants inline. -const PALETTE = [ - '#ffc7c7', '#fff1c7', '#e3ffc7', '#c7ffd5', '#c7ffff', '#c7d5ff', - '#e3c7ff', '#ffc7f1', '#ffa8a8', '#ffe699', '#cfff9e', '#99ffb3', - '#a3ffff', '#99b3ff', '#cc99ff', '#ff99e5', '#e7b1b1', '#e9dcAf', - '#cde9af', '#bfedcc', '#b1e7e7', '#c3cdee', '#d2b8ea', '#eec3e6', - '#e9cece', '#e7e0ca', '#d3e5c7', '#bce1c5', '#c1e2e2', '#c1c9e2', - '#cfc1e2', '#e0bdd9', '#baded3', '#a0f8eb', '#b1e7e0', '#c3c8e4', - '#cec5e2', '#b1d5e7', '#cda8f0', '#f0f0a8', '#f2f2a6', '#f5a8eb', - '#c5f9a9', '#ececbb', '#e7c4bc', '#daf0b2', '#b0a0fd', '#bce2e7', - '#cce2bb', '#ec9afe', '#edabbd', '#aeaeea', '#c4e7b1', '#d722bb', - '#f3a5e7', '#ffa8a8', '#d8c0c5', '#eaaedd', '#adc6eb', '#bedad1', - '#dee9af', '#e9afc2', '#f8d2a0', '#b3b3e6', -]; - -export const ColorSwatch = ({color, size = 14}: Props) => { - let resolved = '#ccc'; - if (typeof color === 'string') { - resolved = color; - } else if (typeof color === 'number' && color >= 0 && color < PALETTE.length) { - resolved = PALETTE[color]; - } - return ; -}; diff --git a/admin/src/components/IconButton.tsx b/admin/src/components/IconButton.tsx index 2ea3c902d..876a55779 100644 --- a/admin/src/components/IconButton.tsx +++ b/admin/src/components/IconButton.tsx @@ -1,14 +1,16 @@ -import {ButtonHTMLAttributes, FC, JSX, ReactElement} from "react"; +import {FC, JSX, ReactElement} from "react"; -export type IconButtonProps = Omit, 'title' | 'onClick'> & { - icon: JSX.Element, - title: string|ReactElement, - onClick: ()=>void, +export type IconButtonProps = { + icon: JSX.Element, + title: string|ReactElement, + onClick: ()=>void, + className?: string, + disabled?: boolean } -export const IconButton: FC = ({icon, className, onClick, title, type = 'button', ...rest}) => ( - -); +export const IconButton:FC = ({icon,className,onClick,title, disabled})=>{ + return +} diff --git a/admin/src/components/SearchField.tsx b/admin/src/components/SearchField.tsx index 1d2110005..62a965d40 100644 --- a/admin/src/components/SearchField.tsx +++ b/admin/src/components/SearchField.tsx @@ -1,14 +1,14 @@ import {ChangeEventHandler, FC} from "react"; import {Search} from 'lucide-react' export type SearchFieldProps = { - value: string, - onChange: ChangeEventHandler, - placeholder?: string + value: string, + onChange: ChangeEventHandler, + placeholder?: string } export const SearchField:FC = ({onChange,value, placeholder})=>{ - return - - - + return + + + } diff --git a/admin/src/components/ShoutType.ts b/admin/src/components/ShoutType.ts index f4b94b9cb..f7e8b1df3 100644 --- a/admin/src/components/ShoutType.ts +++ b/admin/src/components/ShoutType.ts @@ -1,13 +1,13 @@ export type ShoutType = { - type: string, - data:{ type: string, - payload: { - message: { - message: string, - sticky: boolean - }, - timestamp: number + data:{ + type: string, + payload: { + message: { + message: string, + sticky: boolean + }, + timestamp: number + } } - } } diff --git a/admin/src/components/UpdateBanner.tsx b/admin/src/components/UpdateBanner.tsx deleted file mode 100644 index 177035ef8..000000000 --- a/admin/src/components/UpdateBanner.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import {useEffect, useState} from 'react'; -import {Link} from 'react-router-dom'; -import {Trans, useTranslation} from 'react-i18next'; -import {useStore} from '../store/store'; - -const fmtRemaining = (ms: number): string => { - if (ms <= 0) return '0s'; - const s = Math.floor(ms / 1000); - const m = Math.floor(s / 60); - const sec = s % 60; - return m > 0 ? `${m}m ${sec}s` : `${sec}s`; -}; - -export const UpdateBanner = () => { - const {t} = useTranslation(); - const updateStatus = useStore((s) => s.updateStatus); - const setUpdateStatus = useStore((s) => s.setUpdateStatus); - - useEffect(() => { - let cancelled = false; - fetch('/admin/update/status', {credentials: 'same-origin'}) - .then((r) => r.ok ? r.json() : null) - .then((data) => { if (data && !cancelled) setUpdateStatus(data); }) - .catch(() => {}); - return () => { cancelled = true; }; - }, [setUpdateStatus]); - - const scheduledFor = updateStatus?.execution?.status === 'scheduled' - ? (updateStatus.execution as {scheduledFor: string}).scheduledFor - : null; - const [remainingMs, setRemainingMs] = useState(() => - scheduledFor ? Math.max(0, new Date(scheduledFor).getTime() - Date.now()) : 0); - useEffect(() => { - if (!scheduledFor) return; - const target = new Date(scheduledFor).getTime(); - setRemainingMs(Math.max(0, target - Date.now())); - const id = setInterval(() => setRemainingMs(Math.max(0, target - Date.now())), 1000); - return () => clearInterval(id); - }, [scheduledFor]); - - if (!updateStatus) return null; - - // Terminal rollback-failed wins over the regular "update available" banner — - // an admin who left the system in this state needs to fix it before any - // other admin work matters. - if (updateStatus.execution?.status === 'rollback-failed') { - return ( -
- {' '} - {t('update.banner.cta')} -
- ); - } - - // Tier 4: tier is autonomous but the maintenance window isn't usable. - // Surface that before the generic "update available" banner so the admin - // knows the autonomous behavior is sitting idle. - const policyReason = updateStatus.policy?.reason; - if (updateStatus.tier === 'autonomous' - && (policyReason === 'maintenance-window-missing' - || policyReason === 'maintenance-window-invalid')) { - return ( -
- - - {' '} - {t('update.banner.cta')} -
- ); - } - - // Tier 3: scheduled update — show countdown banner instead of the plain - // "update available" one. - if (updateStatus.execution?.status === 'scheduled') { - const exec = updateStatus.execution as {targetTag: string; scheduledFor: string}; - return ( -
- - - {' '} - {t('update.banner.cta')} -
- ); - } - - if (!updateStatus.latest) return null; - if (updateStatus.currentVersion === updateStatus.latest.version) return null; - - return ( -
- {' '} - - - {' '} - {t('update.banner.cta')} -
- ); -}; diff --git a/admin/src/components/settings/FormView.tsx b/admin/src/components/settings/FormView.tsx deleted file mode 100644 index 19cfad7c8..000000000 --- a/admin/src/components/settings/FormView.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { parseTree, getNodePath, type JSONPath, type Node, type ParseError } from 'jsonc-parser'; -import { useStore } from '../../store/store'; -import { useTranslation } from 'react-i18next'; -import { editJsonc } from './jsoncEdit'; -import { JsoncNode } from './JsoncNode'; -import { ParseErrorBanner } from './ParseErrorBanner'; -import { extractAdjacentComments } from './comments'; -import { lookupTemplateComment } from './templateComments'; -import { labelAndHelp } from './labels'; - -type Props = { - onSwitchToRaw: () => void; -}; - -// Parser-error token labels are kept in English — they are technical tokens -// matching the jsonc-parser error enum, not user-facing prose. -const ParseErrorMessage: Record = { - 1: 'Invalid symbol', - 2: 'Invalid number format', - 3: 'Property name expected', - 4: 'Value expected', - 5: 'Colon expected', - 6: 'Comma expected', - 7: 'Closing brace expected', - 8: 'Closing bracket expected', - 9: 'End of file expected', - 16: 'Unexpected end of comment', - 17: 'Unexpected end of string', - 18: 'Unexpected end of number', - 19: 'Invalid unicode', - 20: 'Invalid escape character', - 21: 'Invalid character', -}; - -const formatErrors = (errors: ParseError[]): string => - errors.length === 0 - ? '' - : errors.map(e => `offset ${e.offset}: ${ParseErrorMessage[e.error] ?? 'parse error'}`).join('\n'); - -const Section = ({ title, description, children }: { - title: string; - description?: string; - children: React.ReactNode; -}) => ( -
-
-

{title}

- {description &&

{description}

} -
-
{children}
-
-); - -const propertyKey = (prop: Node): string => - prop.type === 'property' && prop.children?.[0]?.type === 'string' - ? String(prop.children[0].value) - : ''; - -const propertyComment = (prop: Node, text: string, key: string): string | null => { - const valueNode = prop.children?.[1]; - if (!valueNode) return null; - const live = extractAdjacentComments(text, prop.offset, valueNode.offset, valueNode.length); - if (live.leading) return live.leading; - // Section headings prefer the leading block comment; only fall back to the - // trailing comment if no leading documentation exists at all. - const tmpl = lookupTemplateComment([key]); - if (!tmpl) return null; - return tmpl.leading || tmpl.trailing || null; -}; - -export const FormView = ({ onSwitchToRaw }: Props) => { - const { t } = useTranslation(); - const rawText = useStore(s => s.settings); - - // While settings haven't loaded yet, show an empty busy placeholder so we - // don't flash a parse-error banner for the undefined→'' empty-string case. - if (rawText === undefined) { - return
; - } - - const text = rawText; - - const errors: ParseError[] = []; - const tree = parseTree(text, errors, { allowTrailingComma: true }); - - // Always read the latest text from the store instead of closing over the - // render-time snapshot, so rapid sequential edits don't clobber each other. - const onEdit = (path: JSONPath, value: unknown) => { - const current = useStore.getState().settings ?? ''; - useStore.getState().setSettings(editJsonc(current, path, value)); - }; - - if (!tree || errors.length > 0 || tree.type !== 'object') { - return ; - } - - const generalProps: Node[] = []; - const sectionProps: Node[] = []; - for (const prop of tree.children ?? []) { - if (prop.type !== 'property' || !prop.children?.[1]) continue; - const valueType = prop.children[1].type; - if (valueType === 'object' || valueType === 'array') sectionProps.push(prop); - else generalProps.push(prop); - } - - return ( -
- {generalProps.length > 0 && ( -
- {generalProps.map((prop) => { - const propPath = getNodePath(prop); - const propKey = propPath.join('.'); - return ( - - ); - })} -
- )} - {sectionProps.map((prop) => { - const key = propertyKey(prop); - const { label, help } = labelAndHelp(propertyComment(prop, text, key), key); - const propPath = getNodePath(prop); - const sectionKey = propPath.join('.'); - return ( -
- -
- ); - })} -
- ); -}; diff --git a/admin/src/components/settings/JsoncNode.tsx b/admin/src/components/settings/JsoncNode.tsx deleted file mode 100644 index 8cf20b1c6..000000000 --- a/admin/src/components/settings/JsoncNode.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import type { JSONPath, Node } from 'jsonc-parser'; -import { getNodePath } from 'jsonc-parser'; -import { extractAdjacentComments } from './comments'; -import { matchEnvPlaceholder } from './envPill'; -import { lookupTemplateComment } from './templateComments'; -import { humanize, labelAndHelp } from './labels'; -import { StringInput } from './widgets/StringInput'; -import { NumberInput } from './widgets/NumberInput'; -import { BooleanToggle } from './widgets/BooleanToggle'; -import { NullChip } from './widgets/NullChip'; -import { EnvPill } from './widgets/EnvPill'; -import { useResolvedAt } from '../../store/store'; - -type Props = { - /** The value node (not the property node). */ - node: Node; - /** The property node, when this value is the value-side of `"key": value`. */ - property?: Node; - text: string; - onEdit: (path: JSONPath, value: unknown) => void; - /** - * When true, this group's own label/header is suppressed because a - * containing Section already rendered it. The group's children still - * render. Used for top-level object/array sections in FormView. - */ - suppressOwnHeader?: boolean; -}; - -const propertyKey = (property: Node | undefined): string => { - if (!property || property.type !== 'property') return ''; - const k = property.children?.[0]; - return k?.type === 'string' ? String(k.value) : ''; -}; - -const renderLeaf = ( - node: Node, - path: JSONPath, - text: string, - onEdit: (path: JSONPath, value: unknown) => void, - resolvedValue: unknown, -) => { - if (node.type === 'string') { - const raw = text.slice(node.offset, node.offset + node.length); - const env = matchEnvPlaceholder(raw); - if (env) { - return ( - onEdit(path, `\${${env.variable}:${d}}`)} - resolvedValue={resolvedValue} - /> - ); - } - return ( - onEdit(path, v)} - /> - ); - } - if (node.type === 'number') { - return ( - onEdit(path, v)} - /> - ); - } - if (node.type === 'boolean') { - return ( - onEdit(path, v)} - /> - ); - } - if (node.type === 'null') { - return ; - } - return null; -}; - -export const JsoncNode = ({ node, property, text, onEdit, suppressOwnHeader }: Props) => { - const path = getNodePath(node); - const key = propertyKey(property); - // useResolvedAt must be called unconditionally for every JsoncNode - // render (React hook rules). It's cheap: a shallow zustand selector + - // an object-walk that returns undefined when the resolved payload is - // absent (old server) — in which case EnvPill simply omits the chip. - const resolvedValue = useResolvedAt(path); - - const anchor = property ?? node; - const fileComments = extractAdjacentComments(text, anchor.offset, node.offset, node.length); - const tmpl = property ? lookupTemplateComment(path) : null; - const leading = fileComments.leading || tmpl?.leading || ''; - const trailing = fileComments.trailing || tmpl?.trailing || ''; - - // Leading block comments (e.g. /* Description … */ above a key) carry the - // descriptive label — use labelAndHelp's first-sentence split. - // Trailing same-line comments (e.g. "altF9": true, /* focus on … */) are - // brief per-key annotations: the key itself reads as the label, the comment - // belongs in the help slot below the control. See #7740. - let label: string; - let help: string; - if (leading) { - const r = labelAndHelp(leading, key); - label = r.label; - help = [r.help, trailing].filter(Boolean).join(' '); - } else if (trailing) { - label = humanize(key); - help = trailing; - } else { - label = humanize(key); - help = ''; - } - - const rowId = `settings-row-${path.join('.') || 'root'}`; - const helpId = help ? `${rowId}-help` : undefined; - - // ---- Object / array groups ---- - if (node.type === 'object' || node.type === 'array') { - const children = (node.children ?? []).map((child) => { - // For object: child is a property node, drill into its value node. - // For array: child is a value node directly. - if (node.type === 'object') { - const valueNode = child.children?.[1]; - if (!valueNode) return null; - const propPath = getNodePath(child); - const propKey = propPath.join('.'); - return ( - - ); - } - // Array element: use stable JSON path as key. - const childPath = getNodePath(child); - return ; - }); - - if (suppressOwnHeader || !property) { - // Render children flat — the containing Section provides the label. - return <>{children}; - } - - // Nested group within a section: render as a sub-section with its own - // heading, indented under its parent. - return ( -
-
- {label} - {help && {help}} -
-
{children}
-
- ); - } - - // ---- Leaf row ---- - return ( -
- -
- {renderLeaf(node, path, text, onEdit, resolvedValue)} -
- {help && ( -

{help}

- )} -
- ); -}; diff --git a/admin/src/components/settings/ModeToggle.tsx b/admin/src/components/settings/ModeToggle.tsx deleted file mode 100644 index bce7ef81d..000000000 --- a/admin/src/components/settings/ModeToggle.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Trans, useTranslation } from 'react-i18next'; - -export type Mode = 'form' | 'raw' | 'effective'; - -type Props = { - mode: Mode; - onChange: (mode: Mode) => void; - // When false, the Effective tab is hidden. We hide it for installs that - // aren't using env-var substitution at all — there's no useful difference - // between the raw file and the effective in-memory config for them. - showEffective?: boolean; -}; - -export const ModeToggle = ({ mode, onChange, showEffective = false }: Props) => { - const { t } = useTranslation(); - return ( -
- - - {showEffective && ( - - )} -
- ); -}; diff --git a/admin/src/components/settings/ParseErrorBanner.tsx b/admin/src/components/settings/ParseErrorBanner.tsx deleted file mode 100644 index 2a63333f8..000000000 --- a/admin/src/components/settings/ParseErrorBanner.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Trans } from 'react-i18next'; - -type Props = { - message: string; - onSwitchToRaw: () => void; -}; - -export const ParseErrorBanner = ({ message, onSwitchToRaw }: Props) => ( -
- -
{message}
- -
-); diff --git a/admin/src/components/settings/__tests__/comments.test.ts b/admin/src/components/settings/__tests__/comments.test.ts deleted file mode 100644 index d7fe1f6e0..000000000 --- a/admin/src/components/settings/__tests__/comments.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -// admin/src/components/settings/__tests__/comments.test.ts -// -// Regression coverage for https://github.com/ether/etherpad/issues/7740. -// A previous version of findLeading treated any line ending in `*/` as a -// comment continuation; a JSON line like -// "altF9": true, /* focus on the File Menu and/or editbar */ -// then leaked into the next sibling's "leading comment", which the form -// view rendered as the row label. - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; - -import { extractAdjacentComments } from '../comments.ts'; -import { humanize, labelAndHelp } from '../labels.ts'; - -const padShortcutText = `{ - "padShortcutEnabled" : { - "altF9": true, /* focus on the File Menu and/or editbar */ - "altC": true, /* focus on the Chat window */ - "cmdShift2": true, /* shows a gritter popup showing a line author */ - "delete": true, - "return": true, - "esc": true, /* in mozilla versions 14-19 avoid reconnecting pad */ - "cmdS": true /* save a revision */ - } -}`; - -const offsetsFor = (text: string, key: string) => { - const keyOffset = text.indexOf(`"${key}"`); - const valOffset = text.indexOf('true', keyOffset); - return { keyOffset, valOffset, valLength: 4 }; -}; - -test('does not absorb prior JSON line with trailing comment as leading', () => { - const { keyOffset, valOffset, valLength } = offsetsFor(padShortcutText, 'altC'); - const { leading, trailing } = - extractAdjacentComments(padShortcutText, keyOffset, valOffset, valLength); - assert.equal(leading, ''); - assert.equal(trailing, 'focus on the Chat window'); -}); - -test('does not accumulate multiple prior trailing-comment lines', () => { - const { keyOffset, valOffset, valLength } = offsetsFor(padShortcutText, 'cmdShift2'); - const { leading } = - extractAdjacentComments(padShortcutText, keyOffset, valOffset, valLength); - assert.equal(leading, ''); -}); - -test('leading is empty when prior line is plain code (no trailing comment)', () => { - const { keyOffset, valOffset, valLength } = offsetsFor(padShortcutText, 'return'); - const { leading } = - extractAdjacentComments(padShortcutText, keyOffset, valOffset, valLength); - assert.equal(leading, ''); -}); - -test('still recognises JSDoc-style leading block comments', () => { - const text = `{ - /* - * Pad Shortcut Keys - */ - "padShortcutEnabled" : {} -}`; - const keyOffset = text.indexOf('"padShortcutEnabled"'); - const valOffset = text.indexOf('{}'); - const { leading, trailing } = extractAdjacentComments(text, keyOffset, valOffset, 2); - assert.equal(leading, 'Pad Shortcut Keys'); - assert.equal(trailing, ''); -}); - -test('still recognises single-line // leading comments', () => { - const text = `{ - // Whether to enable the thing. - "thing": true -}`; - const keyOffset = text.indexOf('"thing"'); - const valOffset = text.indexOf('true'); - const { leading } = extractAdjacentComments(text, keyOffset, valOffset, 4); - assert.equal(leading, 'Whether to enable the thing.'); -}); - -test('humanize spaces camelCase and capitalises only the first word', () => { - assert.equal(humanize('requireAuthentication'), 'Require authentication'); - assert.equal(humanize('altF9'), 'Alt f9'); -}); - -test('labelAndHelp splits a leading block at the first sentence boundary', () => { - const { label, help } = labelAndHelp( - 'Name your instance! Optional context follows.', - 'title', - ); - assert.equal(label, 'Name your instance!'); - assert.equal(help, 'Optional context follows.'); -}); diff --git a/admin/src/components/settings/comments.ts b/admin/src/components/settings/comments.ts deleted file mode 100644 index 2e1f3ae70..000000000 --- a/admin/src/components/settings/comments.ts +++ /dev/null @@ -1,95 +0,0 @@ -// admin/src/components/settings/comments.ts -// -// Given the source text and a property's `keyOffset` (jsonc-parser's -// Node.offset for the property node), extract: -// - `leading`: the contiguous run of `/* */` or `//` comments -// immediately above the key. At most one blank line is allowed -// between the comment block and the key. -// - `trailing`: a single `// ...` or `/* ... */` on the same line -// as the value, after any trailing comma. - -export type AdjacentComments = { - leading: string; - trailing: string; -}; - -const LINE_BREAK = /\r?\n/; - -const stripCommentMarkers = (raw: string): string => { - // raw is a concatenation of comment tokens separated by newlines. - // Drop /* */ and // markers and trim each line. - return raw - .split(LINE_BREAK) - .map(line => line - .replace(/^\s*\/\*+/, '') - .replace(/\*+\/\s*$/, '') - .replace(/^\s*\*\s?/, '') - .replace(/^\s*\/\/\s?/, '') - .trim()) - .filter(line => line.length > 0) - .join(' '); -}; - -const findLeading = (text: string, keyOffset: number): string => { - // Walk backwards from keyOffset to the start of the line containing it. - const lineStart = text.lastIndexOf('\n', keyOffset - 1) + 1; - let cursor = lineStart; - let blankLineSeen = false; - const collected: string[] = []; - - while (cursor > 0) { - // Look at the previous line. - const prevLineEnd = cursor - 1; // the '\n' before our cursor's line - const prevLineStart = text.lastIndexOf('\n', prevLineEnd - 1) + 1; - const line = text.slice(prevLineStart, prevLineEnd); - const trimmed = line.trim(); - - if (trimmed === '') { - if (blankLineSeen) break; - blankLineSeen = true; - cursor = prevLineStart; - continue; - } - - // A JSON line with a trailing `/* … */` comment (e.g. - // "altF9": true, /* focus on the File Menu and/or editbar */ - // ) ends with `*/` but is NOT a comment continuation. Only treat a - // previous line as part of the leading comment block if it structurally - // opens (`/*`), continues (`*` — JSDoc style, covers ` */` close), or - // is a single-line `//` comment. This matches the comment styles used - // in settings.json.template; #7740. - const isComment = - trimmed.startsWith('//') || - trimmed.startsWith('/*') || - trimmed.startsWith('*'); - - if (!isComment) break; - - collected.unshift(line); - cursor = prevLineStart; - } - - return stripCommentMarkers(collected.join('\n')); -}; - -const findTrailing = (text: string, valueOffset: number, valueLength: number): string => { - // Trailing comments only exist on the same line as the value. If there's - // no newline after the value the file has no line structure (e.g. minified - // settings.json) and `//` inside any later string literal would otherwise - // be matched as a comment. - const lineEnd = text.indexOf('\n', valueOffset + valueLength); - if (lineEnd === -1) return ''; - const slice = text.slice(valueOffset + valueLength, lineEnd); - const m = /,?\s*(\/\/.*|\/\*.*?\*\/)\s*$/.exec(slice); - return m ? stripCommentMarkers(m[1]) : ''; -}; - -export const extractAdjacentComments = ( - text: string, - keyOffset: number, - valueOffset: number, - valueLength: number, -): AdjacentComments => ({ - leading: findLeading(text, keyOffset), - trailing: findTrailing(text, valueOffset, valueLength), -}); diff --git a/admin/src/components/settings/envPill.ts b/admin/src/components/settings/envPill.ts deleted file mode 100644 index 2e31f9c51..000000000 --- a/admin/src/components/settings/envPill.ts +++ /dev/null @@ -1,21 +0,0 @@ -// admin/src/components/settings/envPill.ts -// -// Detect `"${VAR}"` and `"${VAR:default}"` placeholders inside the raw -// slice of a string node. The slice INCLUDES the surrounding quotes, -// because jsonc-parser exposes node.offset/length over the whole literal. - -export type EnvPlaceholder = { - variable: string; - defaultValue: string | null; -}; - -const RE = /^"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::([^}]*))?\}"$/; - -export const matchEnvPlaceholder = (rawSlice: string): EnvPlaceholder | null => { - const m = RE.exec(rawSlice); - if (!m) return null; - return { - variable: m[1], - defaultValue: m[2] ?? null, - }; -}; diff --git a/admin/src/components/settings/jsoncEdit.ts b/admin/src/components/settings/jsoncEdit.ts deleted file mode 100644 index f69303acc..000000000 --- a/admin/src/components/settings/jsoncEdit.ts +++ /dev/null @@ -1,11 +0,0 @@ -// admin/src/components/settings/jsoncEdit.ts -import { applyEdits, modify, type JSONPath } from 'jsonc-parser'; - -const FORMATTING = { - formattingOptions: { tabSize: 2, insertSpaces: true, eol: '\n' as const }, -}; - -export const editJsonc = (text: string, path: JSONPath, value: unknown): string => { - const edits = modify(text, path, value, FORMATTING); - return edits.length === 0 ? text : applyEdits(text, edits); -}; diff --git a/admin/src/components/settings/labels.ts b/admin/src/components/settings/labels.ts deleted file mode 100644 index df5884839..000000000 --- a/admin/src/components/settings/labels.ts +++ /dev/null @@ -1,46 +0,0 @@ -// Pretty-label derivation. The first sentence of a key's documentation -// comment is its label; the rest stays in the help-text slot. When no -// comment exists, fall back to a humanized key name (camelCase → "Camel -// case"). - -const SENTENCE_END = /[.!?](\s|$)/; - -export const humanize = (key: string): string => { - if (!key) return key; - // Split camelCase / PascalCase / snake_case / kebab-case - const words = key - .replace(/[_-]+/g, ' ') - .replace(/([a-z])([A-Z])/g, '$1 $2') - .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') - .toLowerCase() - .trim() - .split(/\s+/); - if (words.length === 0) return key; - return words[0].charAt(0).toUpperCase() + words[0].slice(1) + - (words.length > 1 ? ' ' + words.slice(1).join(' ') : ''); -}; - -const splitFirstSentence = (text: string): { head: string; rest: string } => { - const trimmed = text.trim(); - const m = SENTENCE_END.exec(trimmed); - if (!m) return { head: trimmed, rest: '' }; - const cut = m.index + 1; // include the punctuation - return { - head: trimmed.slice(0, cut).trim(), - rest: trimmed.slice(cut).trim(), - }; -}; - -export const labelAndHelp = ( - comment: string | null | undefined, - key: string, -): { label: string; help: string } => { - if (!comment || !comment.trim()) { - return { label: humanize(key), help: '' }; - } - const { head, rest } = splitFirstSentence(comment); - return { - label: head || humanize(key), - help: rest, - }; -}; diff --git a/admin/src/components/settings/templateComments.ts b/admin/src/components/settings/templateComments.ts deleted file mode 100644 index df9de0bee..000000000 --- a/admin/src/components/settings/templateComments.ts +++ /dev/null @@ -1,50 +0,0 @@ -// Build a fallback path → comment map from `settings.json.template`. The live -// settings.json is per-developer and often lacks comments; the template is the -// authoritative source of per-key documentation. - -import { parseTree, type JSONPath, type Node } from 'jsonc-parser'; -import { extractAdjacentComments, type AdjacentComments } from './comments'; - -// Injected by Vite at build time from settings.json.template (see vite.config.ts). -// Inlining at config time avoids widening the dev server's filesystem allowlist -// to the repo root, which would expose settings.json/credentials.json over the -// dev server. -declare const __SETTINGS_TEMPLATE__: string; -const templateText: string = __SETTINGS_TEMPLATE__; - -const pathKey = (path: JSONPath): string => path.map(String).join('.'); - -const buildMap = (text: string): Map => { - const map = new Map(); - const tree = parseTree(text, [], { allowTrailingComma: true }); - if (!tree) return map; - - const walk = (node: Node, path: JSONPath) => { - if (node.type === 'object') { - for (const prop of node.children ?? []) { - if (prop.type !== 'property' || !prop.children || prop.children.length < 2) continue; - const keyNode = prop.children[0]; - const valueNode = prop.children[1]; - if (keyNode.type !== 'string') continue; - const childPath = [...path, String(keyNode.value)]; - const adjacent = extractAdjacentComments( - text, prop.offset, valueNode.offset, valueNode.length, - ); - if (adjacent.leading || adjacent.trailing) { - map.set(pathKey(childPath), adjacent); - } - walk(valueNode, childPath); - } - } else if (node.type === 'array') { - (node.children ?? []).forEach((child, i) => walk(child, [...path, i])); - } - }; - - walk(tree, []); - return map; -}; - -const templateMap = buildMap(templateText); - -export const lookupTemplateComment = (path: JSONPath): AdjacentComments | null => - templateMap.get(pathKey(path)) ?? null; diff --git a/admin/src/components/settings/widgets/BooleanToggle.tsx b/admin/src/components/settings/widgets/BooleanToggle.tsx deleted file mode 100644 index d2d91fadf..000000000 --- a/admin/src/components/settings/widgets/BooleanToggle.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import * as Switch from '@radix-ui/react-switch'; -import type { JSONPath } from 'jsonc-parser'; - -type Props = { - value: boolean; - path: JSONPath; - onChange: (next: boolean) => void; -}; - -export const BooleanToggle = ({ value, path, onChange }: Props) => ( - - - -); diff --git a/admin/src/components/settings/widgets/EnvPill.tsx b/admin/src/components/settings/widgets/EnvPill.tsx deleted file mode 100644 index 9ec97e949..000000000 --- a/admin/src/components/settings/widgets/EnvPill.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import type { JSONPath } from 'jsonc-parser'; -import type { EnvPlaceholder } from '../envPill'; - -const REDACTED = '[REDACTED]'; - -type Props = { - placeholder: EnvPlaceholder; - path: JSONPath; - onChange: (newDefault: string) => void; - resolvedValue?: unknown; -}; - -const sanitize = (s: string) => s.replace(/[}]/g, ''); - -const formatDisplay = (v: unknown): string => { - if (v === null) return 'null'; - if (typeof v === 'string') return v; - return String(v); -}; - -export const EnvPill = ({ placeholder, path, onChange, resolvedValue }: Props) => { - const { t } = useTranslation(); - const initial = placeholder.defaultValue ?? ''; - const [draft, setDraft] = useState(initial); - const focused = useRef(false); - - useEffect(() => { - if (!focused.current) setDraft(initial); - }, [initial]); - - const id = `field-${path.join('.')}`; - const testid = `env-${path.join('.')}`; - - // Three runtime states: - // undefined → server didn't send resolved (old server, or path absent) - // '[REDACTED]' → secret hidden - // anything else → live runtime value - const hasResolved = resolvedValue !== undefined; - const isRedacted = resolvedValue === REDACTED; - - return ( - - - {placeholder.variable} - - {t('admin_settings.env_pill.default_label')} - - { focused.current = true; }} - onBlur={() => { focused.current = false; }} - onChange={e => { - const v = sanitize(e.target.value); - setDraft(v); - onChange(v); - }} - /> - {hasResolved && !isRedacted && ( - - - - {t('admin_settings.env_pill.runtime_label')} - - - {formatDisplay(resolvedValue)} - - - )} - {isRedacted && ( - - → •••••• - - )} - - ); -}; diff --git a/admin/src/components/settings/widgets/NullChip.tsx b/admin/src/components/settings/widgets/NullChip.tsx deleted file mode 100644 index 9c705fcdd..000000000 --- a/admin/src/components/settings/widgets/NullChip.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { JSONPath } from 'jsonc-parser'; - -type Props = { path: JSONPath }; - -export const NullChip = ({ path }: Props) => ( - null -); diff --git a/admin/src/components/settings/widgets/NumberInput.tsx b/admin/src/components/settings/widgets/NumberInput.tsx deleted file mode 100644 index d339e97a8..000000000 --- a/admin/src/components/settings/widgets/NumberInput.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import type { JSONPath } from 'jsonc-parser'; - -type Props = { - value: number; - path: JSONPath; - onChange: (next: number) => void; -}; - -export const NumberInput = ({ value, path, onChange }: Props) => { - const [draft, setDraft] = useState(String(value)); - const [invalid, setInvalid] = useState(false); - const focusedRef = useRef(false); - - // Sync draft when the prop value changes (e.g. after a server round-trip - // canonicalises the number) — but only when the input is not focused so we - // don't stomp on the user while they are typing. - useEffect(() => { - if (!focusedRef.current) { - setDraft(String(value)); - setInvalid(false); - } - }, [value]); - - return ( - { focusedRef.current = true; }} - onBlur={() => { focusedRef.current = false; }} - onChange={e => { - const next = e.target.value; - setDraft(next); - const parsed = Number(next); - if (next.trim() !== '' && Number.isFinite(parsed)) { - setInvalid(false); - onChange(parsed); - } else { - setInvalid(true); - } - }} - /> - ); -}; diff --git a/admin/src/components/settings/widgets/StringInput.tsx b/admin/src/components/settings/widgets/StringInput.tsx deleted file mode 100644 index dd3efe515..000000000 --- a/admin/src/components/settings/widgets/StringInput.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import type { JSONPath } from 'jsonc-parser'; - -type Props = { - value: string; - path: JSONPath; - onChange: (next: string) => void; -}; - -export const StringInput = ({ value, path, onChange }: Props) => ( - onChange(e.target.value)} - /> -); diff --git a/admin/src/components/settings/widgets/__tests__/EnvPill.test.tsx b/admin/src/components/settings/widgets/__tests__/EnvPill.test.tsx deleted file mode 100644 index 1ea2f3b2f..000000000 --- a/admin/src/components/settings/widgets/__tests__/EnvPill.test.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import * as React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { I18nextProvider } from 'react-i18next'; -import i18next from 'i18next'; - -import { EnvPill } from '../EnvPill.tsx'; - -i18next.init({ - lng: 'en', - resources: { - en: { - translation: { - 'admin_settings.env_pill.tooltip': 'env {{variable}}', - 'admin_settings.env_pill.default_label': 'default', - 'admin_settings.env_pill.input_aria': 'aria {{variable}}', - 'admin_settings.env_pill.runtime_label': 'active value', - 'admin_settings.env_pill.runtime_tooltip': 'using {{variable}}', - 'admin_settings.env_pill.redacted_tooltip': 'hidden {{variable}}', - }, - }, - }, - interpolation: { escapeValue: false }, -}); - -const wrap = (el: React.ReactElement) => - renderToStaticMarkup( - React.createElement(I18nextProvider, { i18n: i18next }, el), - ); - -test('omits runtime chip when resolvedValue is undefined', () => { - const html = wrap(React.createElement(EnvPill, { - placeholder: { variable: 'DB_TYPE', defaultValue: 'dirty' }, - path: ['dbType'], - onChange: () => {}, - })); - assert.ok(!html.includes('settings-widget-env-runtime'), - `runtime chip should be absent, got: ${html}`); -}); - -test('renders runtime chip with resolved value', () => { - const html = wrap(React.createElement(EnvPill, { - placeholder: { variable: 'DB_TYPE', defaultValue: 'dirty' }, - path: ['dbType'], - onChange: () => {}, - resolvedValue: 'sqlite', - } as any)); - assert.ok(html.includes('settings-widget-env-runtime'), - `runtime chip class should appear, got: ${html}`); - assert.ok(html.includes('sqlite'), - `resolved value text should appear, got: ${html}`); -}); - -test('renders redacted chip when resolvedValue is [REDACTED]', () => { - const html = wrap(React.createElement(EnvPill, { - placeholder: { variable: 'DB_PASS', defaultValue: '' }, - path: ['dbSettings', 'password'], - onChange: () => {}, - resolvedValue: '[REDACTED]', - } as any)); - assert.ok(html.includes('settings-widget-env-runtime-redacted'), - `redacted chip class should appear, got: ${html}`); - assert.ok(!html.includes('[REDACTED]'), - `literal sentinel must not be displayed to the user, got: ${html}`); -}); - -test('coerces non-string resolved values to display strings', () => { - const html = wrap(React.createElement(EnvPill, { - placeholder: { variable: 'TRUST_PROXY', defaultValue: 'false' }, - path: ['trustProxy'], - onChange: () => {}, - resolvedValue: true, - } as any)); - assert.ok(html.includes('true'), `expected "true" in ${html}`); -}); - -test('renders null resolved value as the string null', () => { - const html = wrap(React.createElement(EnvPill, { - placeholder: { variable: 'IP', defaultValue: '' }, - path: ['ip'], - onChange: () => {}, - resolvedValue: null, - } as any)); - assert.ok(html.includes('null'), `expected "null" in ${html}`); -}); diff --git a/admin/src/index.css b/admin/src/index.css index 1d63f26a2..acc0d2e97 100644 --- a/admin/src/index.css +++ b/admin/src/index.css @@ -1,29 +1,8 @@ :root { - /* Etherpad green design system */ - --ep-accent: #149474; - --ep-accent-h: #1AAA85; - --ep-accent-d: #0E7257; - --ep-accent-tint: #E6F5F0; - --ep-accent-tint2: #D1ECDF; - --ep-forest: #0E3D32; - --ep-forest-d: #082A22; - --ep-forest-l: #155144; - --ink: rgba(0,0,0,.88); - --ink-2: rgba(0,0,0,.65); - --ink-3: rgba(0,0,0,.45); - --ink-4: rgba(0,0,0,.25); - --bg: #F5F7F6; - --panel: #FFFFFF; - --line: #E7EAE8; - --line-2: #F0F2F1; - --hover: #F7FAF8; - --r: 6px; - --r-lg: 10px; - /* Legacy aliases kept for other pages */ - --etherpad-color: #149474; - --etherpad-comp: #0E3D32; + --etherpad-color: #0f775b; + --etherpad-comp: #9C8840; --etherpad-light: #99FF99; - --sidebar-width: 248px; + --sidebar-width: 20em; } @font-face { @@ -53,16 +32,16 @@ div.menu { left: 0; transition: left .3s; height: 100vh; + font-size: 16px; + font-weight: bolder; display: flex; - align-items: stretch; + align-items: center; + justify-content: center; width: var(--sidebar-width); z-index: 99; position: fixed; } -[role="dialog"] h2 { - color: var(--etherpad-color); -} .icon-button { display: flex; @@ -75,26 +54,6 @@ div.menu { cursor: pointer; } -.icon-button:hover { - background-color: #13a37c; -} - - -.dialog-close-button { - position: absolute; - top: 10px; - right: 10px; - background: none; - border: none; - cursor: pointer; - color: var(--etherpad-color); -} - -.icon-button:active { - background-color: #13a37c; - transform: scale(0.98); -} - .icon-button svg { align-self: center; } @@ -104,22 +63,62 @@ div.menu { } -/* sidebar brand header handled by .sidebar-* classes below */ - -.inner-menu { - flex-grow: 1; - background-color: var(--ep-forest); - color: rgba(255,255,255,.92); - height: 100vh; +div.menu span:first-child { display: flex; - flex-direction: column; - overflow: hidden; + justify-content: center; } -/* legacy menu rules kept for fallback */ -div.menu ul { color: white; padding: 0; } -div.menu svg { align-self: center; } -div.menu li { list-style: none; } +div.menu span:first-child svg { + margin-right: 10px; + align-self: center; +} + + +div.menu h1 { + font-size: 50px; + text-align: center; +} + +.inner-menu { + border-radius: 0 20px 20px 0; + padding: 10px; + flex-grow: 100; + background-color: var(--etherpad-comp); + color: white; + height: 100vh; +} + +div.menu ul { + color: white; + padding: 0; +} + +div.menu li a { + display: flex; + gap: 10px; + margin-bottom: 20px; +} + +div.menu svg { + align-self: center; +} + +div.menu li { + padding: 10px; + color: white; + list-style: none; + margin-left: 3px; + line-height: 3; +} + + +div.menu li:has(.active) { + background-color: #9C885C; +} + +div.menu li a { + color: lightgray; +} div.innerwrapper { @@ -130,7 +129,7 @@ div.innerwrapper { height: 100vh; flex-grow: 100; margin-left: var(--sidebar-width); - padding: 16px 12px; + padding: 20px 20px 20px; } div.innerwrapper-err { @@ -272,9 +271,16 @@ td, th { display: flex; flex-direction: column; gap: 20px; - min-height: 100%; + height: 100%; } +.settings { + flex-grow: max(1, 1); + outline: none; + width: 100%; + resize: none; + font-family: monospace; +} #response { display: inline; @@ -313,18 +319,34 @@ pre { } -/* #icon-button removed — burger is now inside .sidebar-top */ -#icon-button { display: none; } +#icon-button { + color: var(--etherpad-color); + top: 10px; + background-color: transparent; + border: none; + z-index: 99; + position: absolute; + left: 10px; +} -/* sidebar footer handled by .sidebar-footer below */ +.inner-menu span:nth-child(2) { + display: flex; + margin-top: 30px; +} -/* collapsed state handled by the rules at the bottom of this file */ +#wrapper.closed .menu { + left: calc(-1 * var(--sidebar-width)); +} + +#wrapper.closed .innerwrapper { + margin-left: 0; +} @media (max-width: 800px) { div.innerwrapper { - margin-left: 64px; + margin-left: 0; } .inner-menu { @@ -332,9 +354,10 @@ pre { } div.menu { - height: 100vh; + height: auto; border-right: none; - width: 64px; + --sidebar-width: 100%; + float: left; } table { @@ -845,1029 +868,3 @@ input, button, select, optgroup, textarea { display: flex; justify-content: center; } - -.manage-pads-header { - display: flex; -} - -/* Update banner — shown on every admin page when a new version is available. */ -.update-banner { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 14px; - margin: 0 0 12px 0; - background: #fff3cd; - color: #664d03; - border: 1px solid #ffe69c; - border-radius: 4px; - font-size: 14px; -} -.update-banner a { - color: inherit; - text-decoration: underline; - font-weight: 500; -} - -/* Update page layout. */ -.update-page { padding: 16px 0; } -.update-page h1 { margin-bottom: 16px; } -.update-page dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0 0 24px; } -.update-page dt { font-weight: 600; color: #555; } -.update-page dd { margin: 0; } -.update-page pre { background: #f6f8fa; border: 1px solid #d0d7de; border-radius: 4px; padding: 12px; font-size: 13px; max-height: 400px; overflow: auto; } - - -/* ═══════════════════════════════════════════════════════════════════════════ - SIDEBAR — new forest-green design - ═══════════════════════════════════════════════════════════════════════════ */ - -.sidebar-top { - display: flex; - align-items: center; - gap: 10px; - padding: 16px 14px 14px; - border-bottom: 1px solid rgba(255,255,255,.08); - flex-shrink: 0; -} - -.sidebar-burger { - appearance: none; - border: 0; - width: 32px; - height: 32px; - border-radius: var(--r); - background: transparent; - color: rgba(255,255,255,.9); - display: grid; - place-items: center; - cursor: pointer; - flex-shrink: 0; - transition: background .15s; -} -.sidebar-burger:hover { background: rgba(255,255,255,.1); } - -.sidebar-brand { - display: flex; - align-items: center; - gap: 10px; - overflow: hidden; -} - -.sidebar-brand-mark { - width: 32px; - height: 32px; - border-radius: var(--r); - background: rgba(255,255,255,.12); - color: #fff; - display: grid; - place-items: center; - flex-shrink: 0; -} - -.sidebar-brand-name { - font-size: 17px; - font-weight: 600; - letter-spacing: -.01em; - color: #fff; - white-space: nowrap; -} - -.sidebar-nav { - display: flex; - flex-direction: column; - gap: 2px; - padding: 12px 10px; - flex: 1; - overflow-y: auto; -} - -.sidebar-nav .sidebar-nav-item, -.sidebar-nav .sidebar-nav-item:link, -.sidebar-nav .sidebar-nav-item:visited { - position: relative; - display: flex; - align-items: center; - gap: 12px; - padding: 9px 12px; - border-radius: var(--r); - color: rgba(255,255,255,.55); - font-size: 13.5px; - font-weight: 500; - text-decoration: none; - transition: background .15s, color .15s; - white-space: nowrap; - overflow: hidden; -} -.sidebar-nav .sidebar-nav-item:hover { - background: rgba(255,255,255,.08); - color: #fff; - text-decoration: none; -} -.sidebar-nav .sidebar-nav-item.is-active { - background: rgba(255,255,255,.12); - color: #fff; -} -.sidebar-nav .sidebar-nav-item.is-active::before { - content: ''; - position: absolute; - left: 0; - top: 8px; - bottom: 8px; - width: 3px; - border-radius: 0 3px 3px 0; - background: var(--ep-accent-h); -} - -.sidebar-nav-icon { - display: grid; - place-items: center; - flex-shrink: 0; - opacity: .85; -} -.sidebar-nav-item.is-active .sidebar-nav-icon { opacity: 1; } - -.sidebar-nav-label { - overflow: hidden; - text-overflow: ellipsis; -} - -.sidebar-footer { - padding: 14px 16px; - border-top: 1px solid rgba(255,255,255,.08); - font-size: 12px; - flex-shrink: 0; -} - -.sidebar-footer-row { - display: flex; - align-items: center; - gap: 8px; - color: rgba(255,255,255,.8); -} - -.sidebar-status-dot { - width: 7px; - height: 7px; - border-radius: 50%; - background: #5EE3B5; - box-shadow: 0 0 0 3px rgba(94,227,181,.2); - flex-shrink: 0; -} - -/* collapsed sidebar — icon-only at 64px */ -#wrapper.closed .menu { - width: 64px; - left: 0; -} -#wrapper.closed .innerwrapper { - margin-left: 64px; -} -#wrapper.closed .sidebar-top { - justify-content: center; - padding: 16px 0; -} -#wrapper.closed .sidebar-nav-item { - justify-content: center; - padding: 9px 0; -} -#wrapper.closed .sidebar-nav-icon { - opacity: .85; -} - - -/* ═══════════════════════════════════════════════════════════════════════════ - PLUGIN MANAGER PAGE (pm-* classes) - ═══════════════════════════════════════════════════════════════════════════ */ - -.pm-page { - padding: 8px 8px 40px; -} - -.pm-banner { - margin: 8px 0 16px; - padding: 12px 16px; - border-radius: 6px; - border: 1px solid var(--ink-3, #cbd5e1); - background: var(--surface-2, #f8fafc); - font-size: .9rem; -} -.pm-banner-info { border-left: 4px solid var(--accent, #0ea5e9); } - -/* Header */ -.pm-header { - display: flex; - justify-content: space-between; - align-items: flex-end; - gap: 24px; - margin-bottom: 24px; -} - -.pm-crumbs { - font-size: 12px; - color: var(--ink-3); - display: flex; - align-items: center; - gap: 6px; - margin-bottom: 4px; -} -.pm-crumbs-sep { color: var(--ink-4); } - -.pm-title { - font-size: 26px; - font-weight: 600; - letter-spacing: -.015em; - margin: 0 0 4px; - color: var(--ink); - line-height: 1.2; -} - -.pm-subtitle { - font-size: 13.5px; - color: var(--ink-2); - margin: 0; - max-width: 60ch; -} - -.pm-header-actions { - display: flex; - gap: 8px; - flex-shrink: 0; -} - -/* Buttons */ -.pm-btn { - appearance: none; - border: 1px solid transparent; - height: 32px; - padding: 0 12px; - border-radius: var(--r); - font-size: 13px; - font-weight: 500; - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - transition: all .15s; - white-space: nowrap; - text-decoration: none; - font-family: inherit; -} -.pm-btn--sm { height: 28px; padding: 0 10px; font-size: 12px; } - -.pm-btn-primary { - background: var(--ep-accent); - color: #fff; -} -.pm-btn-primary:hover { background: var(--ep-accent-h); } -.pm-btn-primary:active { background: var(--ep-accent-d); } -a.pm-btn-primary:link, a.pm-btn-primary:visited { color: #fff; } - -.pm-btn-ghost { - background: var(--panel); - border-color: var(--line); - color: var(--ink); -} -.pm-btn-ghost:hover { border-color: var(--ep-accent); color: var(--ep-accent-d); } - -/* Stats row */ -.pm-stats { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 12px; - margin-bottom: 24px; -} - -.pm-stat { - background: var(--panel); - border: 1px solid var(--line); - border-radius: var(--r-lg); - padding: 16px 18px; - position: relative; - overflow: hidden; -} - -.pm-stat--primary { - border-color: var(--ep-accent-tint2); - background: linear-gradient(135deg, var(--ep-accent-tint) 0%, #fff 60%); -} -.pm-stat--primary .pm-stat-value { color: var(--ep-accent-d); } - -.pm-stat--warn { - border-color: #FFE7BA; - background: linear-gradient(135deg, #FFFBEB 0%, #fff 60%); -} -.pm-stat--warn .pm-stat-value { color: #B45309; } - -.pm-stat-label { - font-size: 11.5px; - color: var(--ink-3); - text-transform: uppercase; - letter-spacing: .04em; - font-weight: 600; -} - -.pm-stat-value { - font-size: 30px; - font-weight: 600; - letter-spacing: -.02em; - color: var(--ink); - margin: 4px 0 2px; - font-variant-numeric: tabular-nums; - line-height: 1.1; -} -.pm-stat-value--sm { font-size: 18px; } - -.pm-stat-hint { - font-size: 12px; - color: var(--ink-3); -} - -.pm-stat-action { - appearance: none; - border: 0; - background: transparent; - color: #B45309; - font-size: 12px; - font-weight: 600; - padding: 0; - margin-top: 6px; - cursor: pointer; - display: block; - font-family: inherit; -} -.pm-stat-action:hover { text-decoration: underline; } - -/* Sections */ -.pm-section { margin-bottom: 32px; } - -.pm-section-header { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 12px; -} -.pm-section-header h2 { - font-size: 17px; - font-weight: 600; - letter-spacing: -.01em; - margin: 0; - color: var(--ink); -} - -.pm-count-badge { - font-size: 11px; - font-weight: 600; - padding: 3px 8px; - border-radius: 999px; - background: var(--ep-accent-tint); - color: var(--ep-accent-d); -} - -.pm-spacer { flex: 1; } - -/* Installed list */ -.pm-installed { - background: var(--panel); - border: 1px solid var(--line); - border-radius: var(--r-lg); - overflow: hidden; -} - -.pm-installed-row { - display: grid; - grid-template-columns: 36px 1fr auto; - gap: 14px; - align-items: center; - padding: 13px 18px; - border-bottom: 1px solid var(--line-2); - transition: background .15s; -} -.pm-installed-row:last-child { border-bottom: 0; } -.pm-installed-row:hover { background: var(--hover); } - -.pm-installed-icon { - width: 36px; - height: 36px; - border-radius: var(--r); - background: var(--ep-accent-tint); - color: var(--ep-accent-d); - display: grid; - place-items: center; - flex-shrink: 0; -} - -.pm-installed-main { - min-width: 0; -} - -.pm-installed-title { - display: flex; - align-items: center; - gap: 7px; - flex-wrap: wrap; -} - -.pm-installed-desc { - font-size: 12.5px; - color: var(--ink-3); - margin-top: 2px; -} - -.pm-installed-actions { display: flex; gap: 6px; } - -/* Tags */ -.pm-tag { - display: inline-flex; - align-items: center; - font-size: 10.5px; - font-weight: 600; - padding: 2px 7px; - border-radius: 4px; - letter-spacing: .02em; - text-transform: uppercase; - white-space: nowrap; -} -.pm-tag--core { - background: rgba(20,148,116,.12); - color: var(--ep-accent-d); - border: 1px solid rgba(20,148,116,.3); -} -.pm-tag--ver { - background: var(--line-2); - color: var(--ink-2); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - text-transform: none; - letter-spacing: 0; - font-weight: 500; -} -.pm-tag--popular { - background: rgba(20,148,116,.12); - color: var(--ep-accent-d); - border: 1px solid rgba(20,148,116,.25); -} - -/* Toolbar (search + sort) */ -.pm-toolbar { - display: flex; - gap: 8px; - align-items: center; -} - -.pm-search { - position: relative; - display: flex; - align-items: center; - gap: 8px; - width: 260px; - height: 32px; - padding: 0 10px; - border-radius: var(--r); - background: var(--panel); - border: 1px solid var(--line); - transition: border-color .15s, box-shadow .15s; -} -.pm-search:focus-within { - border-color: var(--ep-accent); - box-shadow: 0 0 0 2px rgba(20,148,116,.15); -} - -.pm-search-icon { color: var(--ink-3); flex-shrink: 0; } -.pm-search:focus-within .pm-search-icon { color: var(--ep-accent-d); } - -.pm-search-input { - flex: 1; - min-width: 0; - border: 0; - outline: 0; - background: transparent; - font-size: 13px; - color: var(--ink); - padding: 0; - height: auto; - font-weight: normal; - box-shadow: none; -} - -.pm-search-clear { - appearance: none; - border: 0; - width: 18px; - height: 18px; - border-radius: 4px; - background: rgba(0,0,0,.06); - display: grid; - place-items: center; - cursor: pointer; - color: var(--ink-3); - flex-shrink: 0; - padding: 0; -} - -.pm-select { - appearance: none; - height: 32px; - padding: 0 28px 0 10px; - border-radius: var(--r); - border: 1px solid var(--line); - background: var(--panel); - font-size: 13px; - color: var(--ink); - cursor: pointer; - font-family: inherit; - background-image: url("data:image/svg+xml;utf8,"); - background-repeat: no-repeat; - background-position: right 10px center; -} -.pm-select:hover { border-color: var(--ep-accent); } - -/* Mono name */ -.pm-mono { - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 13px; -} - -/* Available table — all rules scoped under .pm-table-wrap to beat global table styles */ -.pm-table-wrap { - background: var(--panel); - border: 1px solid var(--line); - border-radius: var(--r-lg); - overflow-x: auto; -} - -.pm-table-wrap table { - width: 100%; - border-collapse: separate; - border-spacing: 0; - font-size: 13px; - box-shadow: none; - margin: 0; - min-width: 0; - font-family: inherit; -} - -.pm-table-wrap table thead tr { - font-size: 12px; - background-color: #FAFBFA; - color: var(--ink-2); -} - -.pm-table-wrap table thead th { - text-align: left; - font-size: 12px; - font-weight: 600; - color: var(--ink-2); - background: #FAFBFA; - padding: 10px 14px; - border-bottom: 1px solid var(--line); - letter-spacing: .01em; - border-radius: 0; -} - -.pm-table-wrap table th:first-child { border-top-left-radius: 0; } -.pm-table-wrap table th:last-child { border-top-right-radius: 0; } - -.pm-table-wrap table tbody tr { - border-bottom: none; - background-color: var(--panel); -} - -.pm-table-wrap table tbody tr:nth-child(even), -.pm-table-wrap table tbody tr:nth-of-type(even) { - background-color: var(--panel); -} - -.pm-table-wrap table tbody tr:last-of-type { - border-bottom: none; -} - -.pm-table-wrap table tbody td { - padding: 13px 14px; - border-bottom: 1px solid var(--line-2); - vertical-align: middle; - color: var(--ink); - background: var(--panel); -} - -.pm-table-wrap table tbody tr:last-child td { border-bottom: 0; } -.pm-table-wrap table tr:nth-child(even) td { background-color: var(--panel); } -.pm-table-wrap table tbody tr:hover td { background: var(--hover); } - -.pm-table-wrap .pm-cell-name { - display: flex; - gap: 10px; - align-items: center; -} - -.pm-table-wrap .pm-cell-icon { - width: 26px; - height: 26px; - border-radius: var(--r); - background: var(--ep-accent-tint); - color: var(--ep-accent-d); - display: grid; - place-items: center; - flex-shrink: 0; -} - -.pm-table-wrap .pm-cell-title { - display: flex; - align-items: center; - gap: 6px; - flex-wrap: wrap; -} - -.pm-table-wrap .pm-cell-desc { color: var(--ink-2); } - -.pm-table-wrap .pm-num { - font-variant-numeric: tabular-nums; - text-align: right; - color: var(--ink-2); -} - -.pm-table-wrap .pm-cell-date { color: var(--ink-3); font-size: 12.5px; font-variant-numeric: tabular-nums; } - -.pm-table-wrap .pm-cell-action { text-align: right; } - -/* Empty state */ -.pm-empty { - text-align: center; - padding: 56px 20px; - background: var(--panel); - border: 1px dashed var(--line); - border-radius: var(--r-lg); -} -.pm-empty-icon { font-size: 44px; color: var(--ink-4); margin-bottom: 10px; } -.pm-empty-title { font-size: 15px; font-weight: 500; color: var(--ink); } - -/* Responsive */ -@media (max-width: 1100px) { - .pm-stats { grid-template-columns: repeat(2, 1fr); } -} -@media (max-width: 760px) { - .pm-page { padding: 16px 16px 40px; } - .pm-header { flex-direction: column; align-items: flex-start; } - .pm-stats { grid-template-columns: 1fr 1fr; } - .pm-toolbar { flex-wrap: wrap; } - .pm-search { width: 100%; } -} - -/* ═══════════════════════════════════════════════════════════════════════════ - Pads page — pm-* extensions - ═══════════════════════════════════════════════════════════════════════════ */ - -/* Filter chips */ -.pm-chips { - display: flex; - flex-wrap: wrap; - gap: 6px; - padding: 0 0 12px; -} -.pm-chip { - height: 28px; - padding: 0 12px; - border-radius: 14px; - border: 1px solid var(--line-1); - background: transparent; - color: var(--ink-2); - font-size: 12px; - font-weight: 500; - cursor: pointer; - transition: background .15s, color .15s, border-color .15s; -} -.pm-chip:hover { background: var(--ep-accent-tint); border-color: var(--ep-accent); color: var(--ep-accent); } -.pm-chip.is-on { background: var(--ep-accent); border-color: var(--ep-accent); color: #fff; } - -/* Bulk action bar */ -.pm-bulk { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 14px; - margin-bottom: 8px; - background: var(--ep-accent-tint); - border: 1px solid color-mix(in srgb, var(--ep-accent) 30%, transparent); - border-radius: 8px; - font-size: 13px; -} -.pm-bulk-count { font-weight: 600; color: var(--ep-accent); } - -/* Danger button */ -.pm-btn-danger { - background: transparent; - color: #c0392b; - border: 1px solid #f5c0bb; -} -.pm-btn-danger:hover { background: #fdf2f2; border-color: #c0392b; } - -/* Icon-only button */ -.pm-btn-icon { - width: 30px; - height: 30px; - padding: 0; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 6px; - border: 1px solid var(--line-1); - background: transparent; - color: var(--ink-2); - cursor: pointer; - transition: background .15s, color .15s; -} -.pm-btn-icon:hover { background: var(--panel); color: var(--ink-1); } -.pm-btn-icon--danger:hover { background: #fdf2f2; color: #c0392b; border-color: #f5c0bb; } - -/* Custom checkbox */ -.pm-check { - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; -} -.pm-check input[type="checkbox"] { - width: 15px; - height: 15px; - accent-color: var(--ep-accent); - cursor: pointer; -} - -/* Pad name cell */ -.pm-pad-name { - display: flex; - align-items: center; - gap: 10px; -} -.pm-pad-mark { - flex-shrink: 0; - width: 28px; - height: 28px; - border-radius: 6px; - background: color-mix(in srgb, var(--ep-accent) 15%, transparent); - color: var(--ep-accent); - display: flex; - align-items: center; - justify-content: center; -} -.pm-pad-mark[data-empty] { - background: var(--panel); - color: var(--ink-3); -} -.pm-pad-title { - font-size: 13px; - font-weight: 500; - color: var(--ink-1); - font-family: 'JetBrains Mono', 'Fira Mono', monospace; -} -.pm-pad-sub { - font-size: 11px; - color: var(--ink-3); - margin-top: 1px; -} - -/* Users pill */ -.pm-users-pill { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 2px 8px; - border-radius: 10px; - background: color-mix(in srgb, var(--ep-accent) 12%, transparent); - color: var(--ep-accent); - font-size: 12px; - font-weight: 600; -} -.pm-users-pill.is-muted { background: var(--panel); color: var(--ink-3); font-weight: 400; } -.pm-users-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--ep-accent); - animation: pm-pulse 2s ease-in-out infinite; -} -@keyframes pm-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: .4; } -} - -/* Timestamp cell */ -.pm-time { - display: flex; - flex-direction: column; - gap: 2px; -} -.pm-time-rel { font-size: 13px; color: var(--ink-1); } -.pm-time-abs { font-size: 11px; color: var(--ink-3); } - -/* Row actions */ -.pm-row-actions { - display: flex; - align-items: center; - gap: 6px; - justify-content: flex-end; -} - -/* Selected / empty row tinting */ -.pm-table-wrap table tbody tr.is-sel td { background: color-mix(in srgb, var(--ep-accent) 6%, transparent) !important; } -.pm-table-wrap table tbody tr.is-empty .pm-pad-title { color: var(--ink-3); } - -/* Pagination */ -.pm-pagination { - display: flex; - align-items: center; - gap: 12px; - padding: 16px 0 0; - justify-content: center; -} -.pm-pagination-info { font-size: 13px; color: var(--ink-2); min-width: 60px; text-align: center; } - -/* ═══════════════════════════════════════════════════════════════════════════ - Help page (Hilfestellung) — pm-* extensions - ═══════════════════════════════════════════════════════════════════════════ */ - -/* Version hero block */ -.pm-help-version { - display: grid; - grid-template-columns: 280px 1fr; - gap: 24px; - background: var(--panel); - border: 1px solid var(--line); - border-radius: var(--r-lg); - padding: 22px 24px; - margin-bottom: 24px; - position: relative; - overflow: hidden; -} -.pm-help-version::before { - content: ""; - position: absolute; left: 0; top: 0; bottom: 0; width: 4px; - background: linear-gradient(180deg, var(--ep-accent) 0%, var(--ep-accent-d) 100%); -} -.pm-hv-main { display: flex; flex-direction: column; gap: 4px; } -.pm-hv-lbl { - font-size: 11px; font-weight: 600; - text-transform: uppercase; letter-spacing: .06em; - color: var(--ink-3); -} -.pm-hv-num { - font-size: 42px; font-weight: 600; line-height: 1.05; - letter-spacing: -.025em; - color: var(--ink); - font-variant-numeric: tabular-nums; - margin: 4px 0 8px; -} -.pm-hv-status { - display: inline-flex; align-items: center; gap: 6px; - font-size: 12.5px; font-weight: 500; - padding: 5px 10px; - border-radius: 999px; - width: fit-content; -} -.pm-hv-status.is-ok { background: var(--ep-accent-tint); color: var(--ep-accent-d); } -.pm-hv-status.is-warn { background: #FEF3C7; color: #92400E; } -.pm-hv-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex-shrink: 0; } -.pm-hv-meta { - display: grid; - grid-template-columns: repeat(5, 1fr); - gap: 16px; - align-content: center; - border-left: 1px solid var(--line); - padding-left: 24px; -} -.pm-hv-cell-lbl { - font-size: 11px; font-weight: 600; - text-transform: uppercase; letter-spacing: .04em; - color: var(--ink-3); margin-bottom: 4px; -} -.pm-hv-cell-val { - font-size: 18px; font-weight: 600; line-height: 1.2; - color: var(--ink); - display: inline-flex; align-items: center; gap: 6px; - font-variant-numeric: tabular-nums; -} -.pm-mono { font-family: ui-monospace, 'JetBrains Mono', Consolas, monospace; font-size: 14px; } -.pm-mini-btn { - appearance: none; border: 0; - background: var(--line-2); - color: var(--ink-3); - border-radius: 4px; - width: 20px; height: 20px; - display: inline-flex; align-items: center; justify-content: center; - cursor: pointer; - transition: background .15s, color .15s; -} -.pm-mini-btn:hover { background: var(--ep-accent-tint); color: var(--ep-accent-d); } - -/* Plugins + Parts two-column grid */ -.pm-help-grid { - display: grid; - grid-template-columns: 1fr 2fr; - gap: 16px; - align-items: start; -} -.pm-help-card { - background: var(--panel); - border: 1px solid var(--line); - border-radius: var(--r-lg); - padding: 18px 20px; -} -.pm-sec-tight { margin-bottom: 14px; padding-bottom: 0; border-bottom: none; } -.pm-sec-tight h2 { font-size: 14px; } - -/* Tag cloud */ -.pm-tag-cloud { display: flex; flex-wrap: wrap; gap: 6px; } - -/* Pills */ -.pm-pill { - display: inline-flex; align-items: center; gap: 5px; - font-size: 12px; font-weight: 500; - padding: 5px 10px; - border-radius: 6px; - background: var(--line-2); - color: var(--ink-2); - border: 1px solid transparent; - transition: background .15s, color .15s, border-color .15s; -} -.pm-pill:hover { background: var(--ep-accent-tint); color: var(--ep-accent-d); border-color: var(--ep-accent-tint2); } -.pm-pill-mono { font-family: ui-monospace, 'JetBrains Mono', Consolas, monospace; font-size: 11.5px; } -.pm-pill-sm { padding: 3px 8px; font-size: 11px; } -.pm-pill-ico { - width: 14px; height: 14px; - display: grid; place-items: center; - border-radius: 3px; - background: var(--ep-accent); - color: #fff; - flex-shrink: 0; -} -.pm-pill-ns { color: var(--ink-3); } -.pm-pill-sep { color: var(--ink-4); margin: 0 1px; } -.pm-pill:hover .pm-pill-ns { color: var(--ep-accent-d); opacity: .7; } - -/* Server/Client tab switcher */ -.pm-tabs { - display: inline-flex; - background: var(--line-2); - border-radius: var(--r); - padding: 3px; - gap: 2px; -} -.pm-tab { - appearance: none; border: 0; - background: transparent; - height: 26px; padding: 0 12px; - border-radius: 4px; - font-size: 12.5px; font-weight: 500; - color: var(--ink-2); - cursor: pointer; - display: inline-flex; align-items: center; gap: 6px; - transition: background .15s, color .15s, box-shadow .15s; -} -.pm-tab:hover { color: var(--ink); } -.pm-tab.is-on { - background: var(--panel); - color: var(--ep-accent-d); - box-shadow: 0 1px 4px rgba(0,0,0,.1); -} -.pm-tab-n { - font-size: 10.5px; font-weight: 600; - padding: 1px 5px; - border-radius: 999px; - background: rgba(0,0,0,.06); - color: var(--ink-3); -} -.pm-tab.is-on .pm-tab-n { background: var(--ep-accent-tint); color: var(--ep-accent-d); } - -/* Hooks list */ -.pm-hooks { - background: var(--panel); - border: 1px solid var(--line); - border-radius: var(--r-lg); - overflow: hidden; -} -.pm-hook { - padding: 14px 20px; - border-bottom: 1px solid var(--line-2); - display: grid; - grid-template-columns: 220px 1fr; - gap: 18px; - align-items: start; -} -.pm-hook:last-child { border-bottom: 0; } -.pm-hook:hover { background: var(--hover); } -.pm-hook-h { display: flex; flex-direction: column; gap: 3px; } -.pm-hook-name { - font-family: ui-monospace, 'JetBrains Mono', Consolas, monospace; - font-size: 13.5px; font-weight: 600; - color: var(--ink); - word-break: break-all; -} -.pm-hook-count { font-size: 11px; color: var(--ink-3); } -.pm-hook-parts { display: flex; flex-wrap: wrap; gap: 4px; } - -@media (max-width: 1100px) { - .pm-help-version { grid-template-columns: 1fr; } - .pm-hv-meta { border-left: 0; padding-left: 0; padding-top: 16px; border-top: 1px solid var(--line); grid-template-columns: repeat(3, 1fr); } - .pm-help-grid { grid-template-columns: 1fr; } - .pm-hook { grid-template-columns: 1fr; } -} diff --git a/admin/src/localization/i18n.ts b/admin/src/localization/i18n.ts index 31f1e5cb3..67ae140e7 100644 --- a/admin/src/localization/i18n.ts +++ b/admin/src/localization/i18n.ts @@ -1,66 +1,57 @@ import i18n from 'i18next' import {initReactI18next} from "react-i18next"; import LanguageDetector from 'i18next-browser-languagedetector' -import type {BackendModule} from 'i18next'; -// Core translations live in /src/locales (shared with the pad UI). Letting -// Vite resolve them via import.meta.glob means each language ships as its own -// hashed JSON chunk, lazy-loaded on demand — no build-time copy step or -// /admin/locales/* express route. Earlier setups copying files into the build -// output were fragile (see https://github.com/ether/etherpad/issues/7586). -const coreLocales = import.meta.glob<{default: Record}>( - '../../../src/locales/*.json'); -const coreLocaleByLang = (language: string) => - coreLocales[`../../../src/locales/${language}.json`]; +import { BackendModule } from 'i18next'; const LazyImportPlugin: BackendModule = { - type: 'backend', - init: function () { - }, - read: async function (language, namespace, callback) { - try { - if (namespace === 'translation') { - const loader = coreLocaleByLang(language); - if (!loader) { - callback(new Error(`No core locale for "${language}"`), null); - return; + 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 mod = await loader(); - callback(null, mod.default); - return; - } - // Plugin namespaces (e.g. ep_admin_pads) are still served as static - // assets from admin/public//.json. - const baseURL = `${import.meta.env.BASE_URL}/${namespace}/${language}.json`; - const res = await fetch(baseURL); - if (!res.ok) { - callback(new Error(`HTTP ${res.status} loading ${baseURL}`), null); - return; - } - callback(null, await res.json()); - } catch (e) { - callback(e instanceof Error ? e : new Error(String(e)), null); - } - }, - save: function () { - }, + const localeJSON = await fetch(baseURL, { + cache: "force-cache" + }) + let json; - create: function () { - /* save the missing translation */ - }, + 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','ep_admin_authors'], - fallbackLng: 'en' - } - ) + .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 index 4ff4d9c4a..5efc26de6 100644 --- a/admin/src/main.tsx +++ b/admin/src/main.tsx @@ -11,11 +11,8 @@ 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 {AuthorPage} from "./pages/AuthorPage.tsx"; import {ToastDialog} from "./utils/Toast.tsx"; import {ShoutPage} from "./pages/ShoutPage.tsx"; -import {UpdatePage} from "./pages/UpdatePage.tsx"; -import {QueryProvider} from './api/QueryProvider.tsx'; const router = createBrowserRouter(createRoutesFromElements( <>}> @@ -24,9 +21,7 @@ const router = createBrowserRouter(createRoutesFromElements( }/> }/> }/> - }/> }/> - }/> }/> @@ -37,13 +32,11 @@ const router = createBrowserRouter(createRoutesFromElements( ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - - - - + + + + + + , ) diff --git a/admin/src/pages/AuthorPage.tsx b/admin/src/pages/AuthorPage.tsx deleted file mode 100644 index 0b5626b25..000000000 --- a/admin/src/pages/AuthorPage.tsx +++ /dev/null @@ -1,300 +0,0 @@ -import {Trans, useTranslation} from "react-i18next"; -import {useEffect, useMemo, useState} from "react"; -import * as Dialog from "@radix-ui/react-dialog"; -import {VisuallyHidden} from "@radix-ui/react-visually-hidden"; -import {ChevronLeft, ChevronRight, Trash2} from "lucide-react"; -import {useStore} from "../store/store.ts"; -import {SearchField} from "../components/SearchField.tsx"; -import {ColorSwatch} from "../components/ColorSwatch.tsx"; -import {IconButton} from "../components/IconButton.tsx"; -import {determineSorting} from "../utils/sorting.ts"; -import {useDebounce} from "../utils/useDebounce.ts"; -import { - AnonymizePreview, AnonymizeResult, AuthorRow, AuthorSearchQuery, - AuthorSearchResult, AuthorSortBy, -} from "../utils/AuthorSearch.ts"; - -type DialogState = - | {phase: 'closed'} - | {phase: 'loading-preview', authorID: string, name: string | null} - | {phase: 'preview', preview: AnonymizePreview} - | {phase: 'committing', preview: AnonymizePreview}; - -export const AuthorPage = () => { - const {t} = useTranslation(); - const settingsSocket = useStore((s) => s.settingsSocket); - const authors = useStore((s) => s.authors); - const setAuthors = useStore((s) => s.setAuthors); - const erasureEnabled = useStore((s) => s.gdprAuthorErasureEnabled); - - const [searchTerm, setSearchTerm] = useState(''); - const [includeErased, setIncludeErased] = useState(false); - const [searchParams, setSearchParams] = useState({ - pattern: '', offset: 0, limit: 12, - sortBy: 'name', ascending: true, includeErased: false, - }); - const [currentPage, setCurrentPage] = useState(0); - const [dialog, setDialog] = useState({phase: 'closed'}); - - const pages = useMemo(() => { - if (!authors) return 0; - return Math.ceil(authors.total / searchParams.limit); - }, [authors, searchParams.limit]); - - useDebounce(() => { - setCurrentPage(0); - setSearchParams((p) => ({...p, pattern: searchTerm, offset: 0})); - }, 500, [searchTerm]); - - useEffect(() => { - setSearchParams((p) => ({...p, includeErased, offset: 0})); - setCurrentPage(0); - }, [includeErased]); - - useEffect(() => { - if (!settingsSocket) return; - settingsSocket.emit('authorLoad', searchParams); - }, [settingsSocket, searchParams]); - - useEffect(() => { - if (!settingsSocket) return; - const onLoad = (data: AuthorSearchResult) => setAuthors(data); - const onPreview = (data: AnonymizePreview) => { - if (data.error) { - useStore.getState().setToastState({ - open: true, success: false, - title: t('ep_admin_authors:erase-error-toast', {error: data.error}), - }); - setDialog({phase: 'closed'}); - return; - } - setDialog((cur) => - cur.phase === 'loading-preview' && cur.authorID === data.authorID - ? {phase: 'preview', preview: data} - : cur); - }; - const onErase = (data: AnonymizeResult) => { - if (data.error) { - useStore.getState().setToastState({ - open: true, success: false, - title: t('ep_admin_authors:erase-error-toast', {error: data.error}), - }); - setDialog({phase: 'closed'}); - return; - } - useStore.getState().setToastState({ - open: true, success: true, - title: t('ep_admin_authors:erase-success-toast', {authorID: data.authorID}), - }); - const cur = useStore.getState().authors; - if (cur) { - setAuthors({ - ...cur, - results: cur.results.map((r): AuthorRow => - r.authorID === data.authorID - ? {...r, name: null, erased: true, mapper: []} - : r), - }); - } - setDialog({phase: 'closed'}); - }; - settingsSocket.on('results:authorLoad', onLoad); - settingsSocket.on('results:anonymizeAuthorPreview', onPreview); - settingsSocket.on('results:anonymizeAuthor', onErase); - return () => { - settingsSocket.off('results:authorLoad', onLoad); - settingsSocket.off('results:anonymizeAuthorPreview', onPreview); - settingsSocket.off('results:anonymizeAuthor', onErase); - }; - }, [settingsSocket, setAuthors, t]); - - const sortBy = (col: AuthorSortBy) => () => { - setCurrentPage(0); - setSearchParams((p) => ({ - ...p, sortBy: col, - ascending: p.sortBy === col ? !p.ascending : true, - offset: 0, - })); - }; - - const openErase = (row: AuthorRow) => { - setDialog({phase: 'loading-preview', authorID: row.authorID, name: row.name}); - settingsSocket?.emit('anonymizeAuthorPreview', {authorID: row.authorID}); - }; - - const commitErase = () => { - if (dialog.phase !== 'preview') return; - setDialog({phase: 'committing', preview: dialog.preview}); - settingsSocket?.emit('anonymizeAuthor', {authorID: dialog.preview.authorID}); - }; - - const lastSeenLabel = (row: AuthorRow) => - row.lastSeen - ? new Date(row.lastSeen).toLocaleString() - : t('ep_admin_authors:never-seen'); - - const mapperLabel = (row: AuthorRow) => { - if (row.mapper.length === 0) return t('ep_admin_authors:no-mappers'); - if (row.mapper.length === 1) return row.mapper[0]; - return `${row.mapper[0]} +${row.mapper.length - 1}`; - }; - - return
- {!erasureEnabled && ( -
- -
- )} - - - - - - - {t('ep_admin_authors:confirm-dialog-title')} - - - {t('ep_admin_authors:confirm-dialog-description')} - - {dialog.phase === 'loading-preview' &&
- -
} - {(dialog.phase === 'preview' || dialog.phase === 'committing') && (() => { - const p = dialog.preview; - return
-

{t('ep_admin_authors:confirm-preview-title', - {name: p.name || p.authorID})}

-

{t('ep_admin_authors:confirm-preview-counters', { - tokenMappings: p.removedTokenMappings, - externalMappings: p.removedExternalMappings, - chatMessages: p.clearedChatMessages, - affectedPads: p.affectedPads, - })}

-

- -

-
- - -
- {dialog.phase === 'committing' &&

- -

} -
; - })()} -
-
-
- - -

- -

-
- - setSearchTerm(v.target.value)} - placeholder={t('ep_admin_authors:search-placeholder')}/> - - - - {authors?.cappedAt != null && ( -

- -

- )} - - - - - - - - - - - - - - {authors?.results.length === 0 && } - {authors?.results.map((row) => ( - - - - - - - - - ))} - -
- - - -
- -
- {row.erased - ? - : (row.name ?? '—')} - - {mapperLabel(row)} - {lastSeenLabel(row)} - {row.authorID} - -
- } - title={} - onClick={() => openErase(row)} - disabled={!erasureEnabled || row.erased}/> -
-
- -
- - {t('ep_admin_authors:page-counter', - {current: currentPage + 1, total: pages})} - -
-
; -}; diff --git a/admin/src/pages/HelpPage.tsx b/admin/src/pages/HelpPage.tsx index c1dfa5efc..dd9695b0a 100644 --- a/admin/src/pages/HelpPage.tsx +++ b/admin/src/pages/HelpPage.tsx @@ -1,225 +1,70 @@ -import {Trans, useTranslation} from "react-i18next"; +import {Trans} from "react-i18next"; import {useStore} from "../store/store.ts"; -import {useEffect, useMemo, useState} from "react"; +import {useEffect, useState} from "react"; import {HelpObj} from "./Plugin.ts"; -import {Copy, Search, X, Plug} from "lucide-react"; export const HelpPage = () => { - const settingsSocket = useStore(state => state.settingsSocket) - const {t} = useTranslation() - const [helpData, setHelpData] = useState() - const [tab, setTab] = useState<'server' | 'client'>('server') - const [q, setQ] = useState('') + 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]) + useEffect(() => { + if(!settingsSocket) return; + settingsSocket?.on('reply:help', (data) => { + setHelpData(data) + }); - const serverHooks = useMemo(() => { - if (!helpData) return [] - return Object.keys(helpData.installedServerHooks).map(hookName => ({ - name: hookName, - parts: Object.keys((helpData.installedServerHooks as Record>)[hookName] ?? {}), - })) - }, [helpData]) + settingsSocket?.emit('help'); + }, [settingsSocket]); - const clientHooks = useMemo(() => { - if (!helpData) return [] - return Object.keys(helpData.installedClientHooks).map(hookName => ({ - name: hookName, - parts: Object.keys(helpData.installedClientHooks[hookName] ?? {}), - })) - }, [helpData]) + 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}
    • )} +
    +
  • )} +
+
+ }) + } - const hooks = tab === 'server' ? serverHooks : clientHooks - const filteredHooks = useMemo(() => { - if (!q.trim()) return hooks - const s = q.toLowerCase() - return hooks.filter(h => - h.name.toLowerCase().includes(s) || h.parts.some(p => p.toLowerCase().includes(s)) - ) - }, [hooks, q]) + if (!helpData) return
- const totalBindings = hooks.reduce((n, h) => n + h.parts.length, 0) + return
+

+
+
+
{helpData?.epVersion}
+
+
{helpData.latestVersion}
+
Git sha
+
{helpData.gitCommit}
+
+

+
    + {helpData.installedPlugins.map((plugin, i) =>
  • {plugin}
  • )} +
- const updateAvailable = helpData - ? helpData.epVersion.localeCompare(helpData.latestVersion, undefined, {numeric: true}) < 0 - : false +

+
    + {helpData.installedParts.map((part, i) =>
  • {part}
  • )} +
- const copyDiag = () => { - if (!helpData) return - navigator.clipboard?.writeText(JSON.stringify({ - version: helpData.epVersion, - latestVersion: helpData.latestVersion, - gitCommit: helpData.gitCommit, - plugins: helpData.installedPlugins.length, - parts: helpData.installedParts.length, - hookBindings: totalBindings, - }, null, 2)) - } +

+ { + renderHooks(helpData.installedServerHooks) + } + +

+ + { + renderHooks(helpData.installedClientHooks) + } +

- if (!helpData) return ( -
-
- ) - - return ( -
- - {/* ── Page header ── */} -
-
-
Admin
-

-

-
-
- -
-
- - {/* ── Version block ── */} -
-
-
-
{helpData.epVersion}
-
- - {updateAvailable - ? t('admin_plugins_info.update_available', {version: helpData.latestVersion}) - : t('admin_plugins_info.up_to_date')} -
-
-
-
-
-
{helpData.latestVersion}
-
-
-
-
- {helpData.gitCommit} - -
-
-
-
-
{helpData.installedPlugins.length}
-
-
-
-
{helpData.installedParts.length}
-
-
-
-
{totalBindings}
-
-
-
- - {/* ── Plugins + Parts ── */} -
-
-
-
-

- {helpData.installedPlugins.length} -
-
- {helpData.installedPlugins.map(p => ( - - - {p} - - ))} -
-
- -
-
-

- {helpData.installedParts.length} -
-
- {helpData.installedParts.map(p => { - const slash = p.indexOf('/') - const ns = slash >= 0 ? p.slice(0, slash) : p - const name = slash >= 0 ? p.slice(slash + 1) : '' - return ( - - {ns} - {name && <>/{name}} - - ) - })} -
-
-
-
- - {/* ── Hooks ── */} -
-
-

- {filteredHooks.length} -
-
-
- - -
-
- - setQ(e.target.value)} - placeholder={t('admin_plugins_info.search_placeholder')} - /> - {q && } -
-
-
- - {filteredHooks.length > 0 ? ( -
- {filteredHooks.map(h => ( -
-
- {h.name} - {t('admin_plugins_info.bindings_label', {count: h.parts.length})} -
-
- {h.parts.map(p => ( - {p} - ))} -
-
- ))} -
- ) : ( -
-
-
-
- )} -
-
- ) } diff --git a/admin/src/pages/HomePage.tsx b/admin/src/pages/HomePage.tsx index dfac8fd79..f5ce0a5ab 100644 --- a/admin/src/pages/HomePage.tsx +++ b/admin/src/pages/HomePage.tsx @@ -3,400 +3,245 @@ import {useEffect, useMemo, useState} from "react"; import {InstalledPlugin, PluginDef, SearchParams} from "./Plugin.ts"; import {useDebounce} from "../utils/useDebounce.ts"; import {Trans, useTranslation} from "react-i18next"; -import {ArrowUpFromDot, Download, ExternalLink, Plug, RefreshCw, Search, Trash, X} from "lucide-react"; +import {SearchField} from "../components/SearchField.tsx"; +import {ArrowUpFromDot, Download, Trash} from "lucide-react"; import {IconButton} from "../components/IconButton.tsx"; +import {determineSorting} from "../utils/sorting.ts"; + export const HomePage = () => { - const pluginsSocket = useStore(state => state.pluginsSocket) - const [plugins, setPlugins] = useState([]) - const [catalogDisabled, setCatalogDisabled] = useState(false) - const installedPlugins = useStore(state => state.installedPlugins) - const setInstalledPlugins = useStore(state => state.setInstalledPlugins) - // Default sort: name ascending. PR #7716 set this to "downloads desc" but - // the backend (src/static/js/pluginfw/installer.ts) never populates - // `downloads`, so the "Most popular" sort/"Popular" tag/Downloads column - // were dead UI — removed alongside this default. + const pluginsSocket = useStore(state=>state.pluginsSocket) + const [plugins,setPlugins] = useState([]) + const installedPlugins = useStore(state=>state.installedPlugins) + const setInstalledPlugins = useStore(state=>state.setInstalledPlugins) const [searchParams, setSearchParams] = useState({ offset: 0, limit: 99999, sortBy: 'name', sortDir: 'asc', - searchTerm: '', + searchTerm: '' }) - const [searchTerm, setSearchTerm] = useState('') - const {t} = useTranslation() - const updatableCount = useMemo( - () => installedPlugins.filter(p => p.updatable).length, - [installedPlugins] - ) - - // "Core" plugins are the ones Etherpad ships as part of the runtime - // (currently just ep_etherpad-lite). Derive from data rather than - // hardcoding 1 — future packaging changes may bundle more. - const coreCount = useMemo( - () => installedPlugins.filter(p => p.name === 'ep_etherpad-lite').length, - [installedPlugins] - ) - - const sortedInstalledPlugins = useMemo( - () => [...installedPlugins].sort((a, b) => a.name.localeCompare(b.name)), - [installedPlugins] - ) - - const filteredInstallablePlugins = useMemo(() => { - return [...plugins].sort((a, b) => { - const dir = searchParams.sortDir === 'asc' ? 1 : -1 - if (searchParams.sortBy === 'version') { - return a.version.localeCompare(b.version) * dir + const filteredInstallablePlugins = useMemo(()=>{ + return plugins.sort((a, b)=>{ + if(searchParams.sortBy === "version"){ + if(searchParams.sortDir === "asc"){ + return a.version.localeCompare(b.version) + } + return b.version.localeCompare(a.version) } - if (searchParams.sortBy === 'last-updated') { - return a.time.localeCompare(b.time) * dir + + if(searchParams.sortBy === "last-updated"){ + if(searchParams.sortDir === "asc"){ + return a.time.localeCompare(b.time) + } + return b.time.localeCompare(a.time) } - return a.name.localeCompare(b.name) * dir + + + if (searchParams.sortBy === "name") { + if(searchParams.sortDir === "asc"){ + return a.name.localeCompare(b.name) + } + return b.name.localeCompare(a.name) + } + return 0 }) }, [plugins, searchParams]) - useEffect(() => { - if (!pluginsSocket) return + const sortedInstalledPlugins = useMemo(()=>{ + return useStore.getState().installedPlugins.sort((a, b)=>{ - const onInstalled = (data: {installed: InstalledPlugin[]}) => { - setInstalledPlugins(data.installed) - } - const onUpdatable = (data: {updatable: string[]}) => { - const updated = useStore.getState().installedPlugins.map(plugin => - data.updatable.includes(plugin.name) ? {...plugin, updatable: true} : plugin - ) - setInstalledPlugins(updated) - } - const onFinishedInstall = (data: {plugin: string; code?: string | null; error?: string | null}) => { - if (data?.error) { - const key = data.code === 'PLUGIN_REQUIRES_NEWER_ETHERPAD' - ? 'admin_plugins.install_error_requires_newer_etherpad' - : 'admin_plugins.install_error' - useStore.getState().setToastState({ - open: true, - title: t(key, {plugin: data.plugin, error: data.error}), - success: false, + if(a.name < b.name){ + return -1 + } + if(a.name > b.name){ + return 1 + } + return 0 }) - } - pluginsSocket.emit('getInstalled') - } - const onFinishedUninstall = () => { - console.log('Finished uninstall') - } - const onConnect = () => { - pluginsSocket.emit('getInstalled') - pluginsSocket.emit('search', searchParams) + + } ,[installedPlugins, searchParams]) + + 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) => { + const newInstalledPlugins = useStore.getState().installedPlugins.map(plugin => { + if (data.updatable.includes(plugin.name)) { + return { + ...plugin, + updatable: true + } + } + return plugin + }) + setInstalledPlugins(newInstalledPlugins) + }) + + 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) + }) + pluginsSocket!.on('results:searcherror', (data: {error: string}) => { + console.log(data.error) + useStore.getState().setToastState({ + open: true, + title: "Error retrieving plugins", + success: false + }) + }) + }, [searchParams, pluginsSocket]); + + const uninstallPlugin = (pluginName: string)=>{ + pluginsSocket!.emit('uninstall', pluginName); + // Remove plugin + setInstalledPlugins(installedPlugins.filter(i=>i.name !== pluginName)) } - const onCatalogDisabled = () => setCatalogDisabled(true) - - pluginsSocket.on('results:installed', onInstalled) - pluginsSocket.on('results:updatable', onUpdatable) - pluginsSocket.on('finished:install', onFinishedInstall) - pluginsSocket.on('finished:uninstall', onFinishedUninstall) - pluginsSocket.on('connect', onConnect) - pluginsSocket.on('results:catalogDisabled', onCatalogDisabled) - - pluginsSocket.emit('getInstalled') - - const interval = setInterval(() => pluginsSocket.emit('checkUpdates'), 1000 * 60 * 5) - return () => { - clearInterval(interval) - pluginsSocket.off('results:installed', onInstalled) - pluginsSocket.off('results:updatable', onUpdatable) - pluginsSocket.off('finished:install', onFinishedInstall) - pluginsSocket.off('finished:uninstall', onFinishedUninstall) - pluginsSocket.off('connect', onConnect) - pluginsSocket.off('results:catalogDisabled', onCatalogDisabled) - } - }, [pluginsSocket]) - - useEffect(() => { - if (!pluginsSocket) return - - const onSearchResults = (data: {results: PluginDef[]}) => { - setPlugins(data.results) - } - const onSearchError = () => { - useStore.getState().setToastState({open: true, title: t('admin_plugins.error_retrieving'), success: false}) + const installPlugin = (pluginName: string)=>{ + pluginsSocket!.emit('install', pluginName); + setPlugins(plugins.filter(plugin=>plugin.name !== pluginName)) } - pluginsSocket.emit('search', searchParams) - pluginsSocket.on('results:search', onSearchResults) - pluginsSocket.on('results:searcherror', onSearchError) + useDebounce(()=>{ + setSearchParams({ + ...searchParams, + offset: 0, + searchTerm: searchTerm + }) + }, 500, [searchTerm]) - return () => { - pluginsSocket.off('results:search', onSearchResults) - pluginsSocket.off('results:searcherror', onSearchError) - } - }, [searchParams, pluginsSocket]) - const uninstallPlugin = (pluginName: string) => { - pluginsSocket!.emit('uninstall', pluginName) - setInstalledPlugins(installedPlugins.filter(i => i.name !== pluginName)) - } + return
+

- const installPlugin = (pluginName: string) => { - pluginsSocket!.emit('install', pluginName) - setPlugins(plugins.filter(p => p.name !== pluginName)) - } +

- useDebounce(() => { - setSearchParams({...searchParams, offset: 0, searchTerm}) - }, 500, [searchTerm]) - - return ( -
- - {catalogDisabled && ( -
- -
- )} - - {/* ── Page header ────────────────────────────────────────────────── */} -
-
-
- Admin -
-

{t('admin_plugins')}

-

- -

-
-
- - - - -
-
- - {/* ── Stats row ──────────────────────────────────────────────────── */} -
-
-
-
{installedPlugins.length}
-
{t('admin_plugins.core_count', {count: coreCount})}
-
-
-
-
{plugins.length}
-
-
0 ? ' pm-stat--warn' : ''}`}> -
-
{updatableCount}
- {updatableCount > 0 && ( - - )} -
-
-
-
npm
-
registry.npmjs.org
-
-
- - {/* ── Installed plugins ──────────────────────────────────────────── */} -
-
-

- {installedPlugins.length} -
- -
- -
- {sortedInstalledPlugins.map(plugin => ( -
-
- -
-
-
- {plugin.name} - {plugin.name === 'ep_etherpad-lite' && ( - - )} - v{plugin.version} -
- {plugin.description && ( -
{plugin.description}
- )} -
-
- {plugin.updatable ? ( - installPlugin(plugin.name)} - icon={} - title={t('admin_plugins.update_tooltip')} - /> - ) : ( - } - title={} - onClick={() => uninstallPlugin(plugin.name)} - /> - )} -
-
- ))} -
-
- - {/* ── Available plugins ──────────────────────────────────────────── */} -
-
-

- {filteredInstallablePlugins.length} -
-
-
- - setSearchTerm(e.target.value)} - placeholder={t('admin_plugins.available_search.placeholder')} - /> - {searchTerm && ( - - )} -
- - -
-
- - {filteredInstallablePlugins.length > 0 ? ( -
- - - - - - - - - - - - {filteredInstallablePlugins.map(plugin => ( - +
+ + + + + + + + + {sortedInstalledPlugins.map((plugin, index) => { + return + + - - - - - - ))} - -
{plugin.name}{plugin.version} -
- - -
+ { + plugin.updatable ? + installPlugin(plugin.name)} icon={} title="Update"> + : } title={} onClick={() => uninstallPlugin(plugin.name)}/> + }
- {plugin.description} - {plugin.disables && plugin.disables.length > 0 && ( -
- {' '} - {plugin.disables - .map(tag => tag.replace(/^@feature:/, '')) - .join(', ')} -
- )} -
{plugin.version}{plugin.time} - -
-
- ) : ( -
-
-
- {searchTerm === '' - ? - : } -
-
- )} -
+ + })} + + + + +

+ {setSearchTerm(v.target.value)}} placeholder={t('admin_plugins.available_search.placeholder')} value={searchTerm}/> + +
+ + + + + + + + + + + + {(filteredInstallablePlugins.length > 0) ? + filteredInstallablePlugins.map((plugin) => { + return + + + + + + + }) + : + + } + +
{ + setSearchParams({ + ...searchParams, + sortBy: 'name', + sortDir: searchParams.sortDir === "asc"? "desc": "asc" + }) + }}> + { + setSearchParams({ + ...searchParams, + sortBy: 'version', + sortDir: searchParams.sortDir === "asc"? "desc": "asc" + }) + }}>{ + setSearchParams({ + ...searchParams, + sortBy: 'last-updated', + sortDir: searchParams.sortDir === "asc"? "desc": "asc" + }) + }}>
{plugin.name}{plugin.description}{plugin.version}{plugin.time} + } onClick={() => installPlugin(plugin.name)} title={}/> +
{searchTerm == '' ? : }
+
- ) } diff --git a/admin/src/pages/LoginScreen.tsx b/admin/src/pages/LoginScreen.tsx index bfa269c75..61ac8993e 100644 --- a/admin/src/pages/LoginScreen.tsx +++ b/admin/src/pages/LoginScreen.tsx @@ -3,61 +3,59 @@ import {useNavigate} from "react-router-dom"; import {SubmitHandler, useForm} from "react-hook-form"; import {Eye, EyeOff} from "lucide-react"; import {useState} from "react"; -import {useTranslation} from "react-i18next"; type Inputs = { - username: string - password: string + username: string + password: string } export const LoginScreen = ()=>{ - const navigate = useNavigate() - const [passwordVisible, setPasswordVisible] = useState(false) - const {t} = useTranslation() + const navigate = useNavigate() + const [passwordVisible, setPasswordVisible] = useState(false) - const { - register, - handleSubmit} = useForm() + const { + register, + handleSubmit} = useForm() - const login: SubmitHandler = ({username,password})=>{ - fetch('/admin-auth/', { - method: 'POST', - headers:{ - Authorization: `Basic ${btoa(`${username}:${password}`)}` - } - }).then(r=>{ - if(!r.ok) { - useStore.getState().setToastState({ - open: true, - title: t('admin_login.failed'), - success: false + const login: SubmitHandler = ({username,password})=>{ + 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) }) - } else { - navigate('/') - } - }).catch(e=>{ - console.error(e) - }) - } + } - return
-
-

{t('admin_login.title')}

-
-
{t('admin_login.username')}
- -
{t('admin_login.password')}
- - - {passwordVisible? setPasswordVisible(!passwordVisible)}/> : - setPasswordVisible(!passwordVisible)}/>} - - -
+ return
+
+

Etherpad

+
+
Username
+ +
Password
+ + + {passwordVisible? setPasswordVisible(!passwordVisible)}/> : + setPasswordVisible(!passwordVisible)}/>} + + +
+
-
} diff --git a/admin/src/pages/PadPage.tsx b/admin/src/pages/PadPage.tsx index d1f851a58..b5db854f5 100644 --- a/admin/src/pages/PadPage.tsx +++ b/admin/src/pages/PadPage.tsx @@ -1,469 +1,221 @@ import {Trans, useTranslation} from "react-i18next"; import {useEffect, useMemo, useState} from "react"; import {useStore} from "../store/store.ts"; -import {PadFilter, PadSearchQuery, PadSearchResult} from "../utils/PadSearch.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"; -import {VisuallyHidden} from "@radix-ui/react-visually-hidden"; -import {ChevronLeft, ChevronRight, Eye, Trash2, FileStack, PlusIcon, Search, X, RefreshCw, History} from "lucide-react"; -import {useForm} from "react-hook-form"; -import type {TFunction} from "i18next"; +import {IconButton} from "../components/IconButton.tsx"; +import {ChevronLeft, ChevronRight, Eye, Trash2, FileStack} from "lucide-react"; +import {SearchField} from "../components/SearchField.tsx"; -type PadCreateProps = { padName: string } - -const PAD_FILTER_IDS: PadFilter[] = ['all', 'active', 'recent', 'empty', 'stale'] - -function relativeTime(t: TFunction, ts: number): string { - const d = (Date.now() - ts) / 1000 - if (d < 60) return t('admin_pads.relative.just_now') - if (d < 3600) return t('admin_pads.relative.minutes', {count: Math.floor(d / 60)}) - if (d < 86400) return t('admin_pads.relative.hours', {count: Math.floor(d / 3600)}) - if (d < 86400 * 7) return t('admin_pads.relative.days', {count: Math.floor(d / 86400)}) - if (d < 86400 * 30) return t('admin_pads.relative.weeks', {count: Math.floor(d / 86400 / 7)}) - if (d < 86400 * 365) return t('admin_pads.relative.months', {count: Math.floor(d / 86400 / 30)}) - return t('admin_pads.relative.years', {count: Math.floor(d / 86400 / 365)}) -} - -function fmtDate(locale: string, ts: number): string { - const d = new Date(ts) - return ( - d.toLocaleDateString(locale, {day: '2-digit', month: 'short', year: 'numeric'}) + - ' · ' + - d.toLocaleTimeString(locale, {hour: '2-digit', minute: '2-digit'}) - ) -} - -// i18next's language detector reads ?lng= from the URL, so the value can be -// attacker-controlled and structurally invalid (e.g. "en_US", "💥", " "). -// Intl.* throws RangeError on bad tags, which would crash the pads page -// during render. Normalise underscores → dashes and let the Intl runtime -// tell us which subset of the tag it can support; on failure, fall back to -// 'en' to mirror i18next's fallbackLng so dates render in a sane locale -// rather than the user's browser default fighting the page copy. -function sanitizeLocale(lng?: string): string { - if (!lng) return 'en' - const normalized = lng.trim().replace(/_/g, '-') - if (!normalized) return 'en' - try { - const [supported] = Intl.DateTimeFormat.supportedLocalesOf([normalized]) - return supported ?? 'en' - } catch { - return 'en' - } -} - -export const PadPage = () => { - const settingsSocket = useStore(state => state.settingsSocket) - const [searchParams, setSearchParams] = useState({ - offset: 0, limit: 12, pattern: '', sortBy: 'lastEdited', ascending: false, filter: 'all', - }) - const {t, i18n} = useTranslation() - const locale = sanitizeLocale(i18n.resolvedLanguage ?? i18n.language) - const [searchTerm, setSearchTerm] = useState('') - // Read filter off searchParams so chip changes round-trip through - // the server (`filter` is applied before pagination there). Clicking - // a chip used to filter only the current 12-row page slice. - // - // All searchParams mutations go through functional updaters because the - // debounced pattern handler captures a render-time snapshot and would - // otherwise revert a faster chip click / sort change made in between. - const filter: PadFilter = searchParams.filter ?? 'all' - const setFilter = (f: PadFilter) => { - setCurrentPage(0) - setSearchParams((sp) => ({...sp, filter: f, offset: 0})) - } - const [selected, setSelected] = useState>(new Set()) - const pads = useStore(state => state.pads) - const [currentPage, setCurrentPage] = useState(0) - const [deleteDialog, setDeleteDialog] = useState(false) - const [errorText, setErrorText] = useState(null) - const [padToDelete, setPadToDelete] = useState('') - const [createPadDialogOpen, setCreatePadDialogOpen] = useState(false) - const {register, handleSubmit} = useForm() - - const pages = useMemo( - () => pads ? Math.ceil(pads.total / searchParams.limit) : 0, - [pads, searchParams.limit] - ) - - // The server applies `filter` before paginating; the page payload is - // already the filtered slice. The stats cards still reflect the - // current page (pre-existing behaviour) — making them global would - // require a separate aggregate query. - const visibleResults = pads?.results ?? [] - const totalUsers = useMemo(() => visibleResults.reduce((s, p) => s + p.userCount, 0), [pads]) - const activeCount = useMemo(() => visibleResults.filter(p => p.userCount > 0).length, [pads]) - const emptyCount = useMemo(() => visibleResults.filter(p => p.revisionNumber === 0).length, [pads]) - const lastActivity = useMemo(() => { - return visibleResults.length ? Math.max(...visibleResults.map(p => p.lastEdited)) : null - }, [pads]) - - const allSelected = visibleResults.length > 0 && visibleResults.every(p => selected.has(p.padName)) - const toggleAll = () => { - const s = new Set(selected) - if (allSelected) visibleResults.forEach(p => s.delete(p.padName)) - else visibleResults.forEach(p => s.add(p.padName)) - setSelected(s) - } - const toggleOne = (name: string) => { - const s = new Set(selected) - s.has(name) ? s.delete(name) : s.add(name) - setSelected(s) - } - - useDebounce(() => { - // Functional updater so this delayed callback can't clobber a faster - // user interaction (e.g. clicking a filter chip mid-typing). - setSearchParams((sp) => ({...sp, pattern: searchTerm, offset: 0})) - setCurrentPage(0) - }, 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) +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 [currentPage, setCurrentPage] = useState(0) + const [deleteDialog, setDeleteDialog] = useState(false) + const [errorText, setErrorText] = useState(null) + const [padToDelete, setPadToDelete] = useState('') + const pages = useMemo(()=>{ + if(!pads){ + return 0; + } - settingsSocket.on('results:deletePad', (padID: string) => { - const newPads = useStore.getState().pads?.results?.filter(p => p.padName !== padID) - useStore.getState().setPads({total: useStore.getState().pads!.total - 1, results: newPads}) - }) + return Math.ceil(pads!.total / searchParams.limit) + },[pads, searchParams.limit]) + + useDebounce(()=>{ + setSearchParams({ + ...searchParams, + pattern: searchTerm + }) + + }, 500, [searchTerm]) + + useEffect(() => { + if(!settingsSocket){ + return + } - type CreateResponse = {error: string} | {success: string} - settingsSocket.on('results:createPad', (rep: CreateResponse) => { - if ('error' in rep) { - useStore.getState().setToastState({open: true, title: rep.error, success: false}) - } else { - useStore.getState().setToastState({open: true, title: rep.success, success: true}) - setCreatePadDialogOpen(false) settingsSocket.emit('padLoad', searchParams) - } - }) - settingsSocket.on('results:cleanupPadRevisions', (data) => { - const newPads = useStore.getState().pads?.results ?? [] - if (data.error) { setErrorText(data.error); return } - newPads.forEach(p => { if (p.padName === data.padId) p.revisionNumber = data.keepRevisions }) - useStore.getState().setPads({results: newPads, total: useStore.getState().pads!.total}) - }) - }, [settingsSocket, pads]) + }, [settingsSocket, searchParams]); - const deletePad = (id: string) => settingsSocket?.emit('deletePad', id) - const cleanupPad = (id: string) => settingsSocket?.emit('cleanupPadRevisions', id) - const onPadCreate = (data: PadCreateProps) => settingsSocket?.emit('createPad', {padName: data.padName}) + useEffect(() => { + if(!settingsSocket){ + return + } - return ( -
+ settingsSocket.on('results:padLoad', (data: PadSearchResult)=>{ + useStore.getState().setPads(data); + }) - {/* ── Dialogs ── */} - - - - - {t('admin_pads.delete_pad_dialog_title')} - -
{t('ep_admin_pads:ep_adminpads2_confirm', {padID: padToDelete})}
-
-
- - -
-
+ + 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.on('results:cleanupPadRevisions', (data)=>{ + let newPads = useStore.getState().pads?.results ?? [] + + if (data.error) { + setErrorText(data.error) + return + } + + newPads.forEach((pad)=>{ + if (pad.padName === data.padId) { + pad.revisionNumber = data.keepRevisions + } + }) + + useStore.getState().setPads({ + results: newPads, + total: useStore.getState().pads!.total + }) + }) + }, [settingsSocket, pads]); + + const deletePad = (padID: string)=>{ + settingsSocket?.emit('deletePad', padID) + } + + const cleanupPad = (padID: string)=>{ + settingsSocket?.emit('cleanupPadRevisions', padID) + } + + + return
+ + + +
+
+
+ {t("ep_admin_pads:ep_adminpads2_confirm", { + padID: padToDelete, + })} +
+
+ + +
+
+
-
- - - - - - {t('admin_pads.error_prefix')} - -
{t('admin_pads.error_prefix')}: {errorText}
-
-
- -
-
-
-
- - - - - - - {t('admin_pads.create_pad_dialog_description')} -
- -
- - + + + + + +
+
Error occured: {errorText}
+
+ +
- - -
-
-
- - {/* ── Page header ── */} -
-
-
Admin
-

-

-
-
- - -
-
- - {/* ── Stats ── */} -
-
-
-
{pads?.total ?? '—'}
-
{activeCount > 0 - ? t('admin_pads.stats.users_active', {count: activeCount}) - : t('admin_pads.stats.no_active_users')}
-
-
-
-
{totalUsers}
-
-
-
0 ? ' pm-stat--warn' : ''}`}> -
-
{emptyCount}
-
- {emptyCount > 0 && ( - - )} -
-
-
-
- {lastActivity ? relativeTime(t, lastActivity) : '—'} -
-
{pads?.results?.[0]?.padName ?? ''}
-
-
- - {/* ── Pads section ── */} -
-
-

- {visibleResults.length} -
-
-
- - setSearchTerm(e.target.value)} - placeholder={t('ep_admin_pads:ep_adminpads2_search-heading')} - /> - {searchTerm && ( - - )} -
- - -
-
- - {/* Filter chips */} -
- {PAD_FILTER_IDS.map(id => ( - - ))} -
- - {/* Bulk bar */} - {selected.size > 0 && ( -
- {t('admin_pads.selected_count', {count: selected.size})} -
- - - -
- )} - - {visibleResults.length > 0 ? ( -
- - - - - - - - - - - - - {visibleResults.map(pad => { - const isEmpty = pad.revisionNumber === 0 - const isSel = selected.has(pad.padName) - return ( - - - +
- -
- - -
- - - -
-
{pad.padName}
-
- {isEmpty - ? t('admin_pads.empty_never_edited') - : t('admin_pads.revisions_count', {count: pad.revisionNumber})} + + + +

+ setSearchTerm(v.target.value)} placeholder={t('ep_admin_pads:ep_adminpads2_search-heading')}/> + + + + + + + + + + + + { + pads?.results?.map((pad)=>{ + return + + + + + - - - - + - ) - })} - -
{ + setSearchParams({ + ...searchParams, + sortBy: 'padName', + ascending: !searchParams.ascending + }) + }}>{ + setSearchParams({ + ...searchParams, + sortBy: 'userCount', + ascending: !searchParams.ascending + }) + }}>{ + setSearchParams({ + ...searchParams, + sortBy: 'lastEdited', + ascending: !searchParams.ascending + }) + }}>{ + setSearchParams({ + ...searchParams, + sortBy: 'revisionNumber', + ascending: !searchParams.ascending + }) + }}>Revision number
{pad.padName}{pad.userCount}{new Date(pad.lastEdited).toLocaleString()}{pad.revisionNumber} +
+ } title={} onClick={()=>{ + setPadToDelete(pad.padName) + setDeleteDialog(true) + }}/> + } title={} onClick={()=>{ + cleanupPad(pad.padName) + }}/> + } title="view" onClick={()=>window.open(`/p/${pad.padName}`, '_blank')}/>
- - -
- {pad.userCount > 0 ? ( - {pad.userCount} - ) : ( - 0 - )} - {pad.revisionNumber.toLocaleString(locale)} -
- {relativeTime(t, pad.lastEdited)} - {fmtDate(locale, pad.lastEdited)} -
-
-
- - - -
-
-
- ) : ( -
-
-
-
- )} - - {/* Pagination */} -
- - {currentPage + 1} / {pages || 1} - + }) + } +
+
+ + {currentPage+1} out of {pages} +
-
- ) } diff --git a/admin/src/pages/Plugin.ts b/admin/src/pages/Plugin.ts index 4d8c836dc..f5563863b 100644 --- a/admin/src/pages/Plugin.ts +++ b/admin/src/pages/Plugin.ts @@ -1,43 +1,36 @@ export type PluginDef = { - name: string, - description: string, - version: string, - time: string, - official: boolean, - /** - * `@feature:*` Playwright tags for core specs the plugin intentionally - * disables. See doc/PLUGIN_FEATURE_DISABLES.md. May be undefined for - * plugins without a disables list, which is the common case. - */ - disables?: string[], + name: string, + description: string, + version: string, + time: string, + official: boolean, } export type InstalledPlugin = { - name: string, - path: string, - realPath: string, - version: string, - description?: string, - updatable?: boolean + name: string, + path: string, + realPath: string, + version:string, + updatable?: boolean } export type SearchParams = { - searchTerm: string, - offset: number, - limit: number, - sortBy: 'name'|'version'|'last-updated', - sortDir: 'asc'|'desc' + searchTerm: string, + offset: number, + limit: number, + sortBy: 'name'|'version'|'last-updated', + sortDir: 'asc'|'desc' } export type HelpObj = { - epVersion: string - gitCommit: string - installedClientHooks: Record>, - installedParts: string[], - installedPlugins: string[], - installedServerHooks: Record, - latestVersion: string + 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 index 8c5529ea8..f781f67e1 100644 --- a/admin/src/pages/SettingsPage.tsx +++ b/admin/src/pages/SettingsPage.tsx @@ -1,174 +1,50 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { useStore } from '../store/store'; -import { isJSONClean, cleanComments } from '../utils/utils'; -import { Trans, useTranslation } from 'react-i18next'; -import { IconButton } from '../components/IconButton'; -import { RotateCw, Save, AlignLeft, ShieldCheck, Info } from 'lucide-react'; -import { FormView } from '../components/settings/FormView'; -import { ModeToggle, type Mode } from '../components/settings/ModeToggle'; +import {useStore} from "../store/store.ts"; +import {isJSONClean, cleanComments} from "../utils/utils.ts"; +import {Trans} from "react-i18next"; +import {IconButton} from "../components/IconButton.tsx"; +import {RotateCw, Save} from "lucide-react"; -const TAB_INDENT = ' '; +export const SettingsPage = ()=>{ + const settingsSocket = useStore(state=>state.settingsSocket) + const settings = cleanComments(useStore(state=>state.settings)) -// Heuristic: `${VAR}` or `${VAR:default}` in the file means the operator is -// running with env-var substitution (overwhelmingly Docker / Kubernetes). -// We use this to gate the Docker-aware UX (the explanatory banner and the -// Effective-config tab) so non-container installs see the existing UI -// unchanged. Conservative on purpose — false negatives just keep the old -// behaviour. -const ENV_VAR_PATTERN = /\$\{[A-Za-z_][A-Za-z0-9_]*(?::[^}]*)?\}/; - -export const SettingsPage = () => { - const { t } = useTranslation(); - const settingsSocket = useStore(state => state.settingsSocket); - const settings = useStore(state => state.settings) ?? ''; - const resolved = useStore(state => state.resolved); - - const usesEnvVars = useMemo(() => ENV_VAR_PATTERN.test(settings), [settings]); - - const [mode, setMode] = useState('form'); - const [exposeExperimental] = useState(false); - - // The Effective tab is only meaningful when there is a `resolved` - // payload AND the file uses substitution. Falling back to Raw on - // either condition keeps the toggle honest if the user opens this - // page against an older server. - const canShowEffective = usesEnvVars && resolved != null; - useEffect(() => { - if (mode === 'effective' && !canShowEffective) setMode('raw'); - }, [mode, canShowEffective]); - - // Tab in textarea inserts two spaces instead of moving focus; rAF restores caret position after React re-renders. - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key !== 'Tab') return; - e.preventDefault(); - const target = e.currentTarget; - const { selectionStart, selectionEnd, value } = target; - const next = value.substring(0, selectionStart) + TAB_INDENT + value.substring(selectionEnd); - useStore.getState().setSettings(next); - requestAnimationFrame(() => { - target.selectionStart = target.selectionEnd = selectionStart + TAB_INDENT.length; - }); - }; - - const showToast = (titleKey: string, success: boolean) => { - useStore.getState().setToastState({ open: true, title: t(titleKey), success }); - }; - - const testJSON = () => { - if (isJSONClean(settings)) showToast('admin_settings.toast.validation_ok', true); - else showToast('admin_settings.toast.validation_failed', false); - }; - - const prettifyJSON = () => { - try { - const obj = JSON.parse(cleanComments(settings) ?? ''); - if (window.confirm(t('admin_settings.prettify_confirm'))) { - useStore.getState().setSettings(JSON.stringify(obj, null, 2)); - } - } catch { - showToast('admin_settings.toast.prettify_failed', false); - } - }; - - const handleSave = () => { - if (!isJSONClean(settings)) return showToast('admin_settings.toast.json_invalid', false); - if (!settingsSocket?.connected) return showToast('admin_settings.toast.disconnected', false); - // Toast is shown by the saveprogress socket listener in App.tsx on server ack. - settingsSocket.emit('saveSettings', settings); - }; - - const effectiveJson = useMemo(() => { - if (resolved == null) return ''; - try { return JSON.stringify(resolved, null, 2); } catch { return ''; } - }, [resolved]); - - return ( -
-

- - {usesEnvVars && ( -
-