diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 041f7c1a9..f32093ac4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -30,7 +30,8 @@ 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: + - Is the server free of plugins: + - Are you using any abstraction IE docker? **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 d9f660907..822bd8b74 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 new file mode 100644 index 000000000..f482d60a1 --- /dev/null +++ b/admin/public/ep_admin_authors/en.json @@ -0,0 +1,33 @@ +{ + "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 new file mode 100644 index 000000000..7fb454c11 --- /dev/null +++ b/admin/scripts/__tests__/merge-openapi.test.mjs @@ -0,0 +1,74 @@ +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 new file mode 100644 index 000000000..6c229e802 --- /dev/null +++ b/admin/scripts/dump-spec.ts @@ -0,0 +1,57 @@ +// 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 new file mode 100644 index 000000000..d96383e25 --- /dev/null +++ b/admin/scripts/gen-api.mjs @@ -0,0 +1,78 @@ +// 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 new file mode 100644 index 000000000..ff78576c7 --- /dev/null +++ b/admin/scripts/merge-openapi.mjs @@ -0,0 +1,56 @@ +// 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 e69de29bb..c0e02cd0e 100644 --- a/admin/src/App.css +++ b/admin/src/App.css @@ -0,0 +1,347 @@ +/* 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 ae23ab3d3..d7395251b 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} from "lucide-react"; +import {Cable, Construction, Crown, NotepadText, Wrench, PhoneCall, LucideMenu, Bell, Users} from "lucide-react"; +import {UpdateBanner} from "./components/UpdateBanner"; 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'], - }) + 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; pluginsSocket.on('connect', () => { useStore.getState().setPluginsSocket(pluginsSocket); }); - settingSocket.on('connect', () => { + authErrored = false; useStore.getState().setSettingsSocket(settingSocket); useStore.getState().setShowLoading(false) settingSocket.emit('load'); @@ -52,32 +52,52 @@ 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') { - settingSocket.connect(); - } + if (reason === 'io server disconnect' && !authErrored) settingSocket.connect(); }); - settingSocket.on('settings', (settings) => { - /* Check whether the settings.json is authorized to be viewed */ + settingSocket.on('settings', (settings: any) => { + if (settings && typeof settings.flags === 'object' && settings.flags) { + useStore.getState().setGdprAuthorErasureEnabled( + !!settings.flags.gdprAuthorErasure); + } if (settings.results === 'NOT_ALLOWED') { console.log('Not allowed to view settings.json') + useStore.getState().setResolved(null); return; } - - /* Check to make sure the JSON is clean before proceeding */ - if (isJSONClean(settings.results)) { - setSettings(settings.results); - } else { - alert('Invalid JSON'); - } + 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) => { - console.log(status) + 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}); + } else { + const detail = payload?.message ?? ''; + setToastState({open: true, title: t('admin_settings.toast.save_failed') + (detail ? ` (${detail})` : ''), success: 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); }) return () => { @@ -86,35 +106,109 @@ export const App = () => { } }, []); - return
- -
-
- - -

Etherpad

-
-
    { - if (window.innerWidth < 768) { - setSidebarOpen(false) - } - }}> -
  • -
  • -
  • -
  • -
  • Communication
  • -
+ const closeOnMobile = () => { + if (window.innerWidth < 768) setSidebarOpen(false) + } + + return ( +
+ +
+
+
+ + {sidebarOpen && ( +
+
+ Etherpad +
+ )} +
+ + + + {sidebarOpen && ( +
+
+ + {version ? `v${version}` : 'Etherpad'} +
+
+ )} +
+
+ +
+ +
- -
- -
-
+ ) } export default App diff --git a/admin/src/api/QueryProvider.tsx b/admin/src/api/QueryProvider.tsx new file mode 100644 index 000000000..54ee2d95c --- /dev/null +++ b/admin/src/api/QueryProvider.tsx @@ -0,0 +1,40 @@ +// 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 new file mode 100644 index 000000000..23230390b --- /dev/null +++ b/admin/src/api/__tests__/client.test.ts @@ -0,0 +1,20 @@ +// 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 new file mode 100644 index 000000000..39294c976 --- /dev/null +++ b/admin/src/api/client.ts @@ -0,0 +1,30 @@ +// 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 new file mode 100644 index 000000000..f1e9b123f --- /dev/null +++ b/admin/src/components/ColorSwatch.tsx @@ -0,0 +1,37 @@ +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 876a55779..2ea3c902d 100644 --- a/admin/src/components/IconButton.tsx +++ b/admin/src/components/IconButton.tsx @@ -1,16 +1,14 @@ -import {FC, JSX, ReactElement} from "react"; +import {ButtonHTMLAttributes, FC, JSX, ReactElement} from "react"; -export type IconButtonProps = { - icon: JSX.Element, - title: string|ReactElement, - onClick: ()=>void, - className?: string, - disabled?: boolean +export type IconButtonProps = Omit, 'title' | 'onClick'> & { + icon: JSX.Element, + title: string|ReactElement, + onClick: ()=>void, } -export const IconButton:FC = ({icon,className,onClick,title, disabled})=>{ - return -} +export const IconButton: FC = ({icon, className, onClick, title, type = 'button', ...rest}) => ( + +); diff --git a/admin/src/components/SearchField.tsx b/admin/src/components/SearchField.tsx index 62a965d40..1d2110005 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 f7e8b1df3..f4b94b9cb 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, - data:{ - type: string, - payload: { - message: { - message: string, - sticky: boolean - }, - timestamp: number - } + payload: { + message: { + message: string, + sticky: boolean + }, + timestamp: number } + } } diff --git a/admin/src/components/UpdateBanner.tsx b/admin/src/components/UpdateBanner.tsx new file mode 100644 index 000000000..177035ef8 --- /dev/null +++ b/admin/src/components/UpdateBanner.tsx @@ -0,0 +1,104 @@ +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 new file mode 100644 index 000000000..19cfad7c8 --- /dev/null +++ b/admin/src/components/settings/FormView.tsx @@ -0,0 +1,144 @@ +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 new file mode 100644 index 000000000..8cf20b1c6 --- /dev/null +++ b/admin/src/components/settings/JsoncNode.tsx @@ -0,0 +1,181 @@ +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 new file mode 100644 index 000000000..bce7ef81d --- /dev/null +++ b/admin/src/components/settings/ModeToggle.tsx @@ -0,0 +1,53 @@ +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 new file mode 100644 index 000000000..2a63333f8 --- /dev/null +++ b/admin/src/components/settings/ParseErrorBanner.tsx @@ -0,0 +1,16 @@ +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 new file mode 100644 index 000000000..d7fe1f6e0 --- /dev/null +++ b/admin/src/components/settings/__tests__/comments.test.ts @@ -0,0 +1,93 @@ +// 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 new file mode 100644 index 000000000..2e1f3ae70 --- /dev/null +++ b/admin/src/components/settings/comments.ts @@ -0,0 +1,95 @@ +// 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 new file mode 100644 index 000000000..2e31f9c51 --- /dev/null +++ b/admin/src/components/settings/envPill.ts @@ -0,0 +1,21 @@ +// 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 new file mode 100644 index 000000000..f69303acc --- /dev/null +++ b/admin/src/components/settings/jsoncEdit.ts @@ -0,0 +1,11 @@ +// 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 new file mode 100644 index 000000000..df5884839 --- /dev/null +++ b/admin/src/components/settings/labels.ts @@ -0,0 +1,46 @@ +// 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 new file mode 100644 index 000000000..df9de0bee --- /dev/null +++ b/admin/src/components/settings/templateComments.ts @@ -0,0 +1,50 @@ +// 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 new file mode 100644 index 000000000..d2d91fadf --- /dev/null +++ b/admin/src/components/settings/widgets/BooleanToggle.tsx @@ -0,0 +1,20 @@ +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 new file mode 100644 index 000000000..9ec97e949 --- /dev/null +++ b/admin/src/components/settings/widgets/EnvPill.tsx @@ -0,0 +1,96 @@ +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 new file mode 100644 index 000000000..9c705fcdd --- /dev/null +++ b/admin/src/components/settings/widgets/NullChip.tsx @@ -0,0 +1,10 @@ +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 new file mode 100644 index 000000000..d339e97a8 --- /dev/null +++ b/admin/src/components/settings/widgets/NumberInput.tsx @@ -0,0 +1,48 @@ +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 new file mode 100644 index 000000000..dd3efe515 --- /dev/null +++ b/admin/src/components/settings/widgets/StringInput.tsx @@ -0,0 +1,19 @@ +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 new file mode 100644 index 000000000..1ea2f3b2f --- /dev/null +++ b/admin/src/components/settings/widgets/__tests__/EnvPill.test.tsx @@ -0,0 +1,86 @@ +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 acc0d2e97..1d63f26a2 100644 --- a/admin/src/index.css +++ b/admin/src/index.css @@ -1,8 +1,29 @@ :root { - --etherpad-color: #0f775b; - --etherpad-comp: #9C8840; + /* 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-light: #99FF99; - --sidebar-width: 20em; + --sidebar-width: 248px; } @font-face { @@ -32,16 +53,16 @@ div.menu { left: 0; transition: left .3s; height: 100vh; - font-size: 16px; - font-weight: bolder; display: flex; - align-items: center; - justify-content: center; + align-items: stretch; width: var(--sidebar-width); z-index: 99; position: fixed; } +[role="dialog"] h2 { + color: var(--etherpad-color); +} .icon-button { display: flex; @@ -54,6 +75,26 @@ 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; } @@ -63,62 +104,22 @@ div.menu { } -div.menu span:first-child { - display: flex; - justify-content: center; -} - -div.menu span:first-child svg { - margin-right: 10px; - align-self: center; -} - - -div.menu h1 { - font-size: 50px; - text-align: center; -} +/* sidebar brand header handled by .sidebar-* classes below */ .inner-menu { - border-radius: 0 20px 20px 0; - padding: 10px; - flex-grow: 100; - background-color: var(--etherpad-comp); - color: white; + flex-grow: 1; + background-color: var(--ep-forest); + color: rgba(255,255,255,.92); height: 100vh; -} - -div.menu ul { - color: white; - padding: 0; -} - -div.menu li a { display: flex; - gap: 10px; - margin-bottom: 20px; + flex-direction: column; + overflow: hidden; } -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; -} +/* 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.innerwrapper { @@ -129,7 +130,7 @@ div.innerwrapper { height: 100vh; flex-grow: 100; margin-left: var(--sidebar-width); - padding: 20px 20px 20px; + padding: 16px 12px; } div.innerwrapper-err { @@ -271,16 +272,9 @@ td, th { display: flex; flex-direction: column; gap: 20px; - height: 100%; + min-height: 100%; } -.settings { - flex-grow: max(1, 1); - outline: none; - width: 100%; - resize: none; - font-family: monospace; -} #response { display: inline; @@ -319,34 +313,18 @@ pre { } -#icon-button { - color: var(--etherpad-color); - top: 10px; - background-color: transparent; - border: none; - z-index: 99; - position: absolute; - left: 10px; -} +/* #icon-button removed — burger is now inside .sidebar-top */ +#icon-button { display: none; } -.inner-menu span:nth-child(2) { - display: flex; - margin-top: 30px; -} +/* sidebar footer handled by .sidebar-footer below */ -#wrapper.closed .menu { - left: calc(-1 * var(--sidebar-width)); -} - -#wrapper.closed .innerwrapper { - margin-left: 0; -} +/* collapsed state handled by the rules at the bottom of this file */ @media (max-width: 800px) { div.innerwrapper { - margin-left: 0; + margin-left: 64px; } .inner-menu { @@ -354,10 +332,9 @@ pre { } div.menu { - height: auto; + height: 100vh; border-right: none; - --sidebar-width: 100%; - float: left; + width: 64px; } table { @@ -868,3 +845,1029 @@ 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 67ae140e7..31f1e5cb3 100644 --- a/admin/src/localization/i18n.ts +++ b/admin/src/localization/i18n.ts @@ -1,57 +1,66 @@ 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'); -import { BackendModule } from 'i18next'; +const coreLocaleByLang = (language: string) => + coreLocales[`../../../src/locales/${language}.json`]; const LazyImportPlugin: BackendModule = { - type: 'backend', - init: function () { - }, - read: async function (language, namespace, callback) { - - let baseURL = import.meta.env.BASE_URL - if(namespace === "translation") { - // If default we load the translation file - baseURL+=`/locales/${language}.json` - } else { - // Else we load the former plugin translation file - baseURL+=`/${namespace}/${language}.json` + 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; } + 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); + } + }, - const localeJSON = await fetch(baseURL, { - cache: "force-cache" - }) - let json; + save: function () { + }, - 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 */ - }, + create: function () { + /* save the missing translation */ + }, }; i18n - .use(LanguageDetector) - .use(LazyImportPlugin) - .use(initReactI18next) - .init( - { - ns: ['translation','ep_admin_pads'], - fallbackLng: 'en' - } - ) + .use(LanguageDetector) + .use(LazyImportPlugin) + .use(initReactI18next) + .init( + { + ns: ['translation','ep_admin_pads','ep_admin_authors'], + fallbackLng: 'en' + } + ) export default i18n diff --git a/admin/src/main.tsx b/admin/src/main.tsx index 5efc26de6..4ff4d9c4a 100644 --- a/admin/src/main.tsx +++ b/admin/src/main.tsx @@ -11,8 +11,11 @@ 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( <>}> @@ -21,7 +24,9 @@ const router = createBrowserRouter(createRoutesFromElements( }/> }/> }/> + }/> }/> + }/> }/> @@ -32,11 +37,13 @@ const router = createBrowserRouter(createRoutesFromElements( ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - - + + + + + + + + , ) diff --git a/admin/src/pages/AuthorPage.tsx b/admin/src/pages/AuthorPage.tsx new file mode 100644 index 000000000..0b5626b25 --- /dev/null +++ b/admin/src/pages/AuthorPage.tsx @@ -0,0 +1,300 @@ +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 dd9695b0a..c1dfa5efc 100644 --- a/admin/src/pages/HelpPage.tsx +++ b/admin/src/pages/HelpPage.tsx @@ -1,70 +1,225 @@ -import {Trans} from "react-i18next"; +import {Trans, useTranslation} from "react-i18next"; import {useStore} from "../store/store.ts"; -import {useEffect, useState} from "react"; +import {useEffect, useMemo, 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 [helpData, setHelpData] = useState(); + const settingsSocket = useStore(state => state.settingsSocket) + const {t} = useTranslation() + const [helpData, setHelpData] = useState() + const [tab, setTab] = useState<'server' | 'client'>('server') + const [q, setQ] = useState('') - useEffect(() => { - if(!settingsSocket) return; - settingsSocket?.on('reply:help', (data) => { - setHelpData(data) - }); + useEffect(() => { + if (!settingsSocket) return + settingsSocket.on('reply:help', (data) => setHelpData(data)) + settingsSocket.emit('help') + }, [settingsSocket]) - settingsSocket?.emit('help'); - }, [settingsSocket]); + const serverHooks = useMemo(() => { + if (!helpData) return [] + return Object.keys(helpData.installedServerHooks).map(hookName => ({ + name: hookName, + parts: Object.keys((helpData.installedServerHooks as Record>)[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 clientHooks = useMemo(() => { + if (!helpData) return [] + return Object.keys(helpData.installedClientHooks).map(hookName => ({ + name: hookName, + parts: Object.keys(helpData.installedClientHooks[hookName] ?? {}), + })) + }, [helpData]) + const hooks = tab === 'server' ? serverHooks : clientHooks - if (!helpData) return
+ 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]) - return
-

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

-
    - {helpData.installedPlugins.map((plugin, i) =>
  • {plugin}
  • )} -
+ const totalBindings = hooks.reduce((n, h) => n + h.parts.length, 0) -

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

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

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

+ 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)) + } + 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 f5ce0a5ab..dfac8fd79 100644 --- a/admin/src/pages/HomePage.tsx +++ b/admin/src/pages/HomePage.tsx @@ -3,245 +3,400 @@ 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 {SearchField} from "../components/SearchField.tsx"; -import {ArrowUpFromDot, Download, Trash} from "lucide-react"; +import {ArrowUpFromDot, Download, ExternalLink, Plug, RefreshCw, Search, Trash, X} 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 installedPlugins = useStore(state=>state.installedPlugins) - const setInstalledPlugins = useStore(state=>state.setInstalledPlugins) + 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 [searchParams, setSearchParams] = useState({ offset: 0, limit: 99999, sortBy: 'name', sortDir: 'asc', - searchTerm: '' + searchTerm: '', }) + const [searchTerm, setSearchTerm] = useState('') + const {t} = useTranslation() - 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) + 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 } - - if(searchParams.sortBy === "last-updated"){ - if(searchParams.sortDir === "asc"){ - return a.time.localeCompare(b.time) - } - return b.time.localeCompare(a.time) + if (searchParams.sortBy === 'last-updated') { + return a.time.localeCompare(b.time) * dir } - - - if (searchParams.sortBy === "name") { - if(searchParams.sortDir === "asc"){ - return a.name.localeCompare(b.name) - } - return b.name.localeCompare(a.name) - } - return 0 + return a.name.localeCompare(b.name) * dir }) }, [plugins, searchParams]) - const sortedInstalledPlugins = useMemo(()=>{ - return useStore.getState().installedPlugins.sort((a, b)=>{ + useEffect(() => { + if (!pluginsSocket) return - if(a.name < b.name){ - return -1 - } - if(a.name > b.name){ - return 1 - } - return 0 + 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, }) - - } ,[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)) + } + pluginsSocket.emit('getInstalled') + } + const onFinishedUninstall = () => { + console.log('Finished uninstall') + } + const onConnect = () => { + pluginsSocket.emit('getInstalled') + pluginsSocket.emit('search', searchParams) } - const installPlugin = (pluginName: string)=>{ - pluginsSocket!.emit('install', pluginName); - setPlugins(plugins.filter(plugin=>plugin.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}) } - useDebounce(()=>{ - setSearchParams({ - ...searchParams, - offset: 0, - searchTerm: searchTerm - }) - }, 500, [searchTerm]) + pluginsSocket.emit('search', searchParams) + pluginsSocket.on('results:search', onSearchResults) + pluginsSocket.on('results:searcherror', onSearchError) + return () => { + pluginsSocket.off('results:search', onSearchResults) + pluginsSocket.off('results:searcherror', onSearchError) + } + }, [searchParams, pluginsSocket]) - return
-

+ const uninstallPlugin = (pluginName: string) => { + pluginsSocket!.emit('uninstall', pluginName) + setInstalledPlugins(installedPlugins.filter(i => i.name !== pluginName)) + } -

+ const installPlugin = (pluginName: string) => { + pluginsSocket!.emit('install', pluginName) + setPlugins(plugins.filter(p => p.name !== pluginName)) + } - - - - - - - - - - {sortedInstalledPlugins.map((plugin, index) => { - return - - - - - })} - -
{plugin.name}{plugin.version} - { - plugin.updatable ? - installPlugin(plugin.name)} icon={} title="Update"> - : } title={} onClick={() => uninstallPlugin(plugin.name)}/> - } -
+ useDebounce(() => { + setSearchParams({...searchParams, offset: 0, searchTerm}) + }, 500, [searchTerm]) + return ( +
-

- {setSearchTerm(v.target.value)}} placeholder={t('admin_plugins.available_search.placeholder')} value={searchTerm}/> + {catalogDisabled && ( +
+ +
+ )} -
- - - - - - - - - - - - {(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 == '' ? : }
+ {/* ── 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 => ( + + + + + + + + ))} + +
+
+ + +
+
+ {plugin.description} + {plugin.disables && plugin.disables.length > 0 && ( +
+ {' '} + {plugin.disables + .map(tag => tag.replace(/^@feature:/, '')) + .join(', ')} +
+ )} +
{plugin.version}{plugin.time} + +
+
+ ) : ( +
+
+
+ {searchTerm === '' + ? + : } +
+
+ )} +
+ ) } diff --git a/admin/src/pages/LoginScreen.tsx b/admin/src/pages/LoginScreen.tsx index 61ac8993e..bfa269c75 100644 --- a/admin/src/pages/LoginScreen.tsx +++ b/admin/src/pages/LoginScreen.tsx @@ -3,59 +3,61 @@ 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 navigate = useNavigate() + const [passwordVisible, setPasswordVisible] = useState(false) + const {t} = useTranslation() - 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: "Login failed", - success: false - }) - } else { - navigate('/') - } - }).catch(e=>{ - console.error(e) + 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 }) - } + } else { + navigate('/') + } + }).catch(e=>{ + console.error(e) + }) + } - return
-
-

Etherpad

-
-
Username
- -
Password
- - - {passwordVisible? setPasswordVisible(!passwordVisible)}/> : - setPasswordVisible(!passwordVisible)}/>} - - -
-
+ return
+
+

{t('admin_login.title')}

+
+
{t('admin_login.username')}
+ +
{t('admin_login.password')}
+ + + {passwordVisible? setPasswordVisible(!passwordVisible)}/> : + setPasswordVisible(!passwordVisible)}/>} + + +
+
} diff --git a/admin/src/pages/PadPage.tsx b/admin/src/pages/PadPage.tsx index b5db854f5..d1f851a58 100644 --- a/admin/src/pages/PadPage.tsx +++ b/admin/src/pages/PadPage.tsx @@ -1,221 +1,469 @@ import {Trans, useTranslation} from "react-i18next"; import {useEffect, useMemo, useState} from "react"; import {useStore} from "../store/store.ts"; -import {PadSearchQuery, PadSearchResult} from "../utils/PadSearch.ts"; +import {PadFilter, 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 {IconButton} from "../components/IconButton.tsx"; -import {ChevronLeft, ChevronRight, Eye, Trash2, FileStack} from "lucide-react"; -import {SearchField} from "../components/SearchField.tsx"; +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"; -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; - } +type PadCreateProps = { padName: string } - return Math.ceil(pads!.total / searchParams.limit) - },[pads, searchParams.limit]) +const PAD_FILTER_IDS: PadFilter[] = ['all', 'active', 'recent', 'empty', 'stale'] - useDebounce(()=>{ - setSearchParams({ - ...searchParams, - pattern: searchTerm - }) - - }, 500, [searchTerm]) - - useEffect(() => { - if(!settingsSocket){ - return - } - - settingsSocket.emit('padLoad', searchParams) - - }, [settingsSocket, searchParams]); - - useEffect(() => { - if(!settingsSocket){ - return - } - - settingsSocket.on('results:padLoad', (data: PadSearchResult)=>{ - useStore.getState().setPads(data); - }) - - - settingsSocket.on('results:deletePad', (padID: string)=>{ - const newPads = useStore.getState().pads?.results?.filter((pad)=>{ - return pad.padName !== padID - }) - useStore.getState().setPads({ - total: useStore.getState().pads!.total-1, - results: newPads - }) - }) - - settingsSocket.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, - })} -
-
- - -
-
-
-
-
- - - - -
-
Error occured: {errorText}
-
- -
-
-
-
-
-

- 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')}/> -
-
-
- - {currentPage+1} out of {pages} - -
-
+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) + }) + + 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}) + }) + + 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]) + + 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}) + + return ( +
+ + {/* ── Dialogs ── */} + + + + + {t('admin_pads.delete_pad_dialog_title')} + +
{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')} +
+ +
+ + +
+ +
+
+
+
+ + {/* ── 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})} +
+
+
+
+ {pad.userCount > 0 ? ( + {pad.userCount} + ) : ( + 0 + )} + {pad.revisionNumber.toLocaleString(locale)} +
+ {relativeTime(t, pad.lastEdited)} + {fmtDate(locale, pad.lastEdited)} +
+
+
+ + + +
+
+
+ ) : ( +
+
+
+
+ )} + + {/* Pagination */} +
+ + {currentPage + 1} / {pages || 1} + +
+
+
+ ) } diff --git a/admin/src/pages/Plugin.ts b/admin/src/pages/Plugin.ts index f5563863b..4d8c836dc 100644 --- a/admin/src/pages/Plugin.ts +++ b/admin/src/pages/Plugin.ts @@ -1,36 +1,43 @@ export type PluginDef = { - name: string, - description: string, - version: string, - time: string, - official: boolean, + 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[], } export type InstalledPlugin = { - name: string, - path: string, - realPath: string, - version:string, - updatable?: boolean + name: string, + path: string, + realPath: string, + version: string, + description?: 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 f781f67e1..8c5529ea8 100644 --- a/admin/src/pages/SettingsPage.tsx +++ b/admin/src/pages/SettingsPage.tsx @@ -1,50 +1,174 @@ -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"; +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'; -export const SettingsPage = ()=>{ - const settingsSocket = useStore(state=>state.settingsSocket) - const settings = cleanComments(useStore(state=>state.settings)) +const TAB_INDENT = ' '; - return
-

-