fix(admin): replace hardcoded German strings with i18n keys (#7735) (#7736)

* fix(admin): replace ~50 hardcoded German strings with i18n keys (#7735)

PR #7716 ("chore: fixed admin design rework") rebuilt admin/src/pages
with literal German copy inline — "Update verfügbar", "Aktualisieren",
"Keine Pads gefunden", "Hook-Bindings", "de-DE" date formatters, etc.
Non-DE users see a French/English/German salad: <Trans i18nKey="…"/>
calls resolve correctly via translatewiki, but every literal stays
German regardless of browser locale.

This change:

  - Adds 90+ keys to src/locales/en.json under admin.*, admin_login.*,
    admin_pads.*, admin_plugins.*, admin_plugins_info.*, admin_settings.*,
    admin_shout.*, and the previously-orphaned update.page.{disabled,
    unauthorized,error}.
  - Replaces every hardcoded literal in admin/src/{App,LoginScreen,
    HomePage,HelpPage,PadPage,SettingsPage,ShoutPage,UpdatePage}.tsx with
    t() or <Trans>.
  - Threads i18n.language into PadPage so relativeTime() and
    toLocale*() honour the user's locale instead of forcing de-DE.

Test coverage:

  - src/tests/backend-new/specs/admin-i18n-source-lint.test.ts (vitest):
    scans admin/src/pages/*.tsx + App.tsx for a denylist of German
    literals introduced by #7716, asserts PadPage no longer hardcodes
    'de-DE', and pins the set of new en.json keys.
  - src/tests/frontend-new/admin-spec/admini18n.spec.ts (Playwright):
    extended to assert rendered English text on every page (Home, Pads,
    Help, Login) and verify no German leakage on the English path.

Non-EN locales pick up translations from translatewiki on its normal
cadence; until then i18next falls back to en.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(admin): reuse existing ep_admin_pads:* keys instead of duplicating

Pre-rework admin already had:
  ep_admin_pads:ep_adminpads2_action       ("Action")
  ep_admin_pads:ep_adminpads2_last-edited  ("Last edited")
  ep_admin_pads:ep_adminpads2_no-results   ("No results")

Initial pass added admin_pads.{col.action, col.last_edited,
sort.last_edited, empty_state} duplicating those — drop the duplicates
from en.json and point PadPage.tsx at the existing translatewiki-fed
keys. Stats/column heads that genuinely didn't exist before
(admin_pads.col.{pad,users,revisions}, the filter chips, relative-time,
pagination, etc.) stay as new keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(admin): address Qodo finding + restore #7716 regressions

Qodo flagged a reliability bug in PadPage on PR #7736: i18n.language
flows from user-controlled ?lng= straight into Intl.* formatters, which
throw RangeError on malformed tags (e.g. 'en_US', '💥'). Crashing the
pads page on a crafted URL.

Wrap the locale in a sanitizeLocale() helper that normalises '_' → '-'
and validates via Intl.DateTimeFormat.supportedLocalesOf(), falling back
to 'en' so dates render in a sane locale rather than the user's browser
default fighting page copy.

Same audit surfaced four additional regressions from #7716 still on
develop, fixed here on-theme:

  - HomePage dropped <a href="https://npmjs.com/..."> wrappers on both
    installed and available plugin rows. Restored with .pm-plugin-link.
  - "Downloads" column / "Most popular" default sort / "Popular" tag
    were dead UI — src/static/js/pluginfw/installer.ts::search() never
    populates `downloads`. Removed the column, default sort, and tag;
    dropped `downloads` from PluginDef + SearchParams.sortBy.
  - PadPage sort dropdown hardcoded `ascending: e.target.value ===
    'padName'`, leaving no way to invert direction. Replaced with a
    paired ↑/↓ button (.pm-sort-dir) for both HomePage and PadPage.
  - "1 Core" stat hint hardcoded count=1. Derived from
    installedPlugins.filter(p => p.name === 'ep_etherpad-lite').length.
  - Deleted orphan modules SearchField.tsx and sorting.ts (no longer
    imported anywhere after #7716).

Tests:

  - admin-i18n-source-lint.test.ts: +3 assertions (sanitizeLocale
    pattern, dead-downloads check, orphan-module deletion, sort-dir
    toggle) → 14 passing.
  - admini18n.spec.ts: +2 assertions (npmjs link on ep_etherpad-lite
    row, sort-direction toggle visible).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(agents): add mandatory i18n + a11y guardrails

PR #7716 ("admin design rework") shipped ~50 hardcoded German literals,
dropped npmjs.com link affordances, removed the sort-direction control
on PadPage, and forced `de-DE` into Intl formatters — none of which the
AGENTS.MD guide explicitly forbade. Document the rules so the next UI
refresh cannot regress these in the same way:

- i18n section spells out which slots must be localised (JSX text,
  placeholders, titles, aria-labels, alts, toasts, options, alerts),
  which API to use per surface (<Trans>/t() in React, data-l10n-id in
  the legacy pad UI, never window._ rebound), where keys live
  (src/locales/en.json — never hand-edit non-EN locales), to reuse
  existing keys before duplicating, pluralisation via _one/_other,
  defaultValue is safety not a substitute, and points at the
  source-lint test that enforces the denylist.

- a11y section spells out the lessons surfaced by the audit: icon-only
  buttons need aria-label AND title (both localised), sort controls
  must be focusable + reversible, semantic HTML over div soup, external
  navigation is <a>, "don't drop affordances when restyling" is a
  hard rule, Playwright specs must assert rendered strings + at least
  one structural affordance for UI changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McLear 2026-05-12 10:09:44 +01:00 committed by GitHub
parent b3faeffc31
commit ff7c4d5310
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 543 additions and 167 deletions

View file

@ -46,6 +46,25 @@ pnpm --filter ep_etherpad-lite run prod # Start production server
- **Comments:** Provide clear comments for complex logic only.
- **Backward Compatibility:** Always ensure compatibility with older versions of the database and configuration files.
### Internationalisation (i18n) — Mandatory
- **Every user-facing string MUST go through i18n.** That means JSX text, `placeholder`, `title`, `aria-label`, `alt`, toast/alert titles, `<option>` labels, error messages, breadcrumbs, empty-state copy — anything a user can see or hear via a screen reader.
- **React (admin SPA):** use `<Trans i18nKey="…"/>` for JSX text and `t('…')` for attribute values. The `t` comes from `useTranslation()`.
- **Pad UI (legacy):** use `data-l10n-id="…"` (html10n) — never bind `window._` directly to `html10n.get` and call it (it's unbound; returns `undefined`).
- **String keys live in `src/locales/en.json`.** Other locales sync from translatewiki on its own cadence — never hand-edit non-EN locale files.
- **Reuse existing keys before inventing new ones.** Check `src/locales/en.json` and the relevant plugin's `static/locale/en.json` (e.g. `admin/public/ep_admin_pads/en.json`) for an existing match. Duplicating a key like `ep_admin_pads:ep_adminpads2_last-edited` as a fresh `admin_pads.col.last_edited` fragments the translation surface for translatewiki.
- **Naming:** dot-namespaced, kebab-or-underscore-cased: `admin_plugins.subtitle`, `admin_pads.filter.all`. Group by page/feature.
- **Pluralisation:** use i18next's `_one`/`_other` suffix forms with `t('key', {count: n})` — never `n > 1 ? 'X items' : '1 item'`.
- **Locale-aware formatters:** pass a sanitised locale to `Intl.*` / `toLocaleString`. `i18n.language` is influenced by `?lng=` (user-controlled) and a malformed tag throws `RangeError`. Use a `sanitizeLocale()` helper that normalises `_``-` and validates via `Intl.DateTimeFormat.supportedLocalesOf()`, falling back to `'en'`.
- **`defaultValue:` in `t()` is for safety, not a substitute** for adding the key to `en.json`. If you're tempted to inline English with `defaultValue:` and skip the key, you're shipping a future-broken translation.
- **Hard prohibition:** literal German / French / Spanish / any non-English in JSX. The denylist test at `src/tests/backend-new/specs/admin-i18n-source-lint.test.ts` enforces this for the admin SPA — extend it when adding new admin files or new known-bad words.
### Accessibility (a11y) — Mandatory
- **Icon-only buttons MUST have `aria-label` AND `title`** (both — screen readers prefer `aria-label`; hover users get `title`). Lucide icons inside a button are not text content. Both labels must be `t('…')`-localised.
- **Sort controls are focusable.** A `<select>` plus a paired direction toggle is fine; a clickable column header is fine; "click invisible part of the row to sort" is not. When restyling, never strip a direction toggle without adding back an equivalent.
- **Semantic HTML over `<div>` soup.** Use `<nav>`, `<main>`, `<button>`, `<a>` (for navigation), `<table>` for tabular data. If a thing navigates externally, it is `<a target="_blank" rel="noopener noreferrer">`, not a click-handler on a `<span>`.
- **Don't drop existing affordances when restyling.** A UI refresh that removes `<a href="https://npmjs.com/{plugin}">` links, removes a sort-direction control, or replaces semantic elements with non-focusable divs is a regression even if it looks nicer. Audit the before/after for: focus order, keyboard reachability, external links, aria-labels, alt text.
- **Tests assert rendered strings + structural affordances.** For UI changes, the Playwright spec must assert at least one rendered translated string (catches broken i18n loading) and one structural affordance you added/preserved (links, toggles, headings).
### Development Workflow
- **Branching:** Work in feature branches. Issue PRs against the `develop` branch. Never PR directly to `master`.
- **Commits:** Maintain a linear history (no merge commits). Use meaningful messages in the format: `submodule: description`.

View file

@ -54,7 +54,7 @@ export const App = () => {
return;
}
if (isJSONClean(settings.results)) setSettings(settings.results);
else alert('Invalid JSON');
else alert(t('admin_settings.invalid_json'));
useStore.getState().setShowLoading(false);
});
@ -79,7 +79,7 @@ export const App = () => {
<button
className="sidebar-burger"
onClick={() => setSidebarOpen(!sidebarOpen)}
aria-label="Toggle sidebar"
aria-label={t('admin.toggle_sidebar')}
>
<LucideMenu size={20}/>
</button>
@ -127,10 +127,10 @@ export const App = () => {
<NavLink
to="/shout"
className={({isActive}) => `sidebar-nav-item${isActive ? ' is-active' : ''}`}
title={sidebarOpen ? undefined : 'Communication'}
title={sidebarOpen ? undefined : t('admin.shout')}
>
<span className="sidebar-nav-icon"><PhoneCall size={18}/></span>
{sidebarOpen && <span className="sidebar-nav-label">Communication</span>}
{sidebarOpen && <span className="sidebar-nav-label"><Trans i18nKey="admin.shout"/></span>}
</NavLink>
<NavLink
to="/update"

View file

@ -1,14 +0,0 @@
import {ChangeEventHandler, FC} from "react";
import {Search} from 'lucide-react'
export type SearchFieldProps = {
value: string,
onChange: ChangeEventHandler<HTMLInputElement>,
placeholder?: string
}
export const SearchField:FC<SearchFieldProps> = ({onChange,value, placeholder})=>{
return <span className="search-field">
<input value={value} onChange={onChange} placeholder={placeholder}/>
<Search/>
</span>
}

View file

@ -75,11 +75,11 @@ export const HelpPage = () => {
<div>
<div className="pm-crumbs">Admin <span className="pm-crumbs-sep"></span> <Trans i18nKey="admin_plugins_info"/></div>
<h1 className="pm-title"><Trans i18nKey="admin_plugins_info"/></h1>
<p className="pm-subtitle">System-Diagnose: installierte Version, registrierte Teile und Hooks.</p>
<p className="pm-subtitle"><Trans i18nKey="admin_plugins_info.subtitle"/></p>
</div>
<div className="pm-header-actions">
<button className="pm-btn pm-btn-ghost" onClick={copyDiag}>
<Copy size={14}/> Diagnose kopieren
<Copy size={14}/> <Trans i18nKey="admin_plugins_info.copy_diagnostics"/>
</button>
</div>
</div>
@ -92,8 +92,8 @@ export const HelpPage = () => {
<div className={`pm-hv-status${updateAvailable ? ' is-warn' : ' is-ok'}`}>
<span className="pm-hv-dot"/>
{updateAvailable
? `Update verfügbar: ${helpData.latestVersion}`
: 'Auf dem neuesten Stand'}
? t('admin_plugins_info.update_available', {version: helpData.latestVersion})
: t('admin_plugins_info.up_to_date')}
</div>
</div>
<div className="pm-hv-meta">
@ -102,13 +102,13 @@ export const HelpPage = () => {
<div className="pm-hv-cell-val">{helpData.latestVersion}</div>
</div>
<div className="pm-hv-cell">
<div className="pm-hv-cell-lbl">Git SHA</div>
<div className="pm-hv-cell-lbl"><Trans i18nKey="admin_plugins_info.git_sha"/></div>
<div className="pm-hv-cell-val pm-mono">
{helpData.gitCommit}
<button
className="pm-mini-btn"
onClick={() => navigator.clipboard?.writeText(helpData.gitCommit)}
title={t('admin_plugins_info.version') + ' kopieren'}
title={t('admin_plugins_info.copy_value', {label: t('admin_plugins_info.git_sha')})}
>
<Copy size={11}/>
</button>
@ -123,7 +123,7 @@ export const HelpPage = () => {
<div className="pm-hv-cell-val">{helpData.installedParts.length}</div>
</div>
<div className="pm-hv-cell">
<div className="pm-hv-cell-lbl">Hook-Bindings</div>
<div className="pm-hv-cell-lbl"><Trans i18nKey="admin_plugins_info.hook_bindings"/></div>
<div className="pm-hv-cell-val">{totalBindings}</div>
</div>
</div>
@ -178,10 +178,10 @@ export const HelpPage = () => {
<div className="pm-toolbar">
<div className="pm-tabs">
<button className={`pm-tab${tab === 'server' ? ' is-on' : ''}`} onClick={() => setTab('server')}>
Server <span className="pm-tab-n">{serverHooks.length}</span>
<Trans i18nKey="admin_plugins_info.tab_server"/> <span className="pm-tab-n">{serverHooks.length}</span>
</button>
<button className={`pm-tab${tab === 'client' ? ' is-on' : ''}`} onClick={() => setTab('client')}>
Client <span className="pm-tab-n">{clientHooks.length}</span>
<Trans i18nKey="admin_plugins_info.tab_client"/> <span className="pm-tab-n">{clientHooks.length}</span>
</button>
</div>
<div className="pm-search">
@ -190,7 +190,7 @@ export const HelpPage = () => {
className="pm-search-input"
value={q}
onChange={e => setQ(e.target.value)}
placeholder="Hook oder Teil suchen…"
placeholder={t('admin_plugins_info.search_placeholder')}
/>
{q && <button className="pm-search-clear" onClick={() => setQ('')}><X size={12}/></button>}
</div>
@ -203,7 +203,7 @@ export const HelpPage = () => {
<div key={h.name} className="pm-hook">
<div className="pm-hook-h">
<span className="pm-hook-name">{h.name}</span>
<span className="pm-hook-count">{h.parts.length} Bindings</span>
<span className="pm-hook-count">{t('admin_plugins_info.bindings_label', {count: h.parts.length})}</span>
</div>
<div className="pm-hook-parts">
{h.parts.map(p => (
@ -216,7 +216,7 @@ export const HelpPage = () => {
) : (
<div className="pm-empty">
<div className="pm-empty-icon"></div>
<div className="pm-empty-title">Keine Hooks gefunden</div>
<div className="pm-empty-title"><Trans i18nKey="admin_plugins_info.no_hooks"/></div>
</div>
)}
</section>

View file

@ -6,24 +6,20 @@ import {Trans, useTranslation} from "react-i18next";
import {ArrowUpFromDot, Download, ExternalLink, Plug, RefreshCw, Search, Trash, X} from "lucide-react";
import {IconButton} from "../components/IconButton.tsx";
const POPULAR_THRESHOLD = 10_000
const fmtDownloads = (n: number): string => {
if (n >= 10_000) return `${Math.round(n / 1000)}k`
if (n >= 1_000) return `${(n / 1000).toFixed(1)}k`
return String(n)
}
export const HomePage = () => {
const pluginsSocket = useStore(state => state.pluginsSocket)
const [plugins, setPlugins] = useState<PluginDef[]>([])
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<SearchParams>({
offset: 0,
limit: 99999,
sortBy: 'downloads',
sortDir: 'desc',
sortBy: 'name',
sortDir: 'asc',
searchTerm: '',
})
const [searchTerm, setSearchTerm] = useState<string>('')
@ -34,6 +30,14 @@ export const HomePage = () => {
[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]
@ -42,9 +46,6 @@ export const HomePage = () => {
const filteredInstallablePlugins = useMemo(() => {
return [...plugins].sort((a, b) => {
const dir = searchParams.sortDir === 'asc' ? 1 : -1
if (searchParams.sortBy === 'downloads') {
return ((b.downloads ?? 0) - (a.downloads ?? 0)) * (dir * -1)
}
if (searchParams.sortBy === 'version') {
return a.version.localeCompare(b.version) * dir
}
@ -104,7 +105,7 @@ export const HomePage = () => {
setPlugins(data.results)
}
const onSearchError = () => {
useStore.getState().setToastState({open: true, title: 'Error retrieving plugins', success: false})
useStore.getState().setToastState({open: true, title: t('admin_plugins.error_retrieving'), success: false})
}
pluginsSocket.emit('search', searchParams)
@ -138,12 +139,11 @@ export const HomePage = () => {
<div className="pm-header">
<div>
<div className="pm-crumbs">
Admin <span className="pm-crumbs-sep"></span> Plugins
Admin <span className="pm-crumbs-sep"></span> <Trans i18nKey="admin_plugins.crumbs"/>
</div>
<h1 className="pm-title">{t('admin_plugins')}</h1>
<p className="pm-subtitle">
Installiere, aktualisiere und entferne Etherpad-Plugins.
Änderungen erfordern einen Server-Neustart.
<Trans i18nKey="admin_plugins.subtitle"/>
</p>
</div>
<div className="pm-header-actions">
@ -151,7 +151,7 @@ export const HomePage = () => {
className="pm-btn pm-btn-ghost"
onClick={() => pluginsSocket?.emit('search', searchParams)}
>
<RefreshCw size={14}/> Katalog neu laden
<RefreshCw size={14}/> <Trans i18nKey="admin_plugins.reload_catalog"/>
</button>
<a
className="pm-btn pm-btn-primary pm-btn-link"
@ -159,7 +159,7 @@ export const HomePage = () => {
target="_blank"
rel="noreferrer"
>
<ExternalLink size={14}/> Auf npm suchen
<ExternalLink size={14}/> <Trans i18nKey="admin_plugins.search_npm"/>
</a>
</div>
</div>
@ -169,26 +169,26 @@ export const HomePage = () => {
<div className="pm-stat pm-stat--primary">
<div className="pm-stat-label"><Trans i18nKey="admin_plugins.installed"/></div>
<div className="pm-stat-value">{installedPlugins.length}</div>
<div className="pm-stat-hint">Davon 1 Core</div>
<div className="pm-stat-hint">{t('admin_plugins.core_count', {count: coreCount})}</div>
</div>
<div className="pm-stat">
<div className="pm-stat-label"><Trans i18nKey="admin_plugins.available"/></div>
<div className="pm-stat-value">{plugins.length}</div>
</div>
<div className={`pm-stat${updatableCount > 0 ? ' pm-stat--warn' : ''}`}>
<div className="pm-stat-label">Updates verfügbar</div>
<div className="pm-stat-label"><Trans i18nKey="admin_plugins.updates_available"/></div>
<div className="pm-stat-value">{updatableCount}</div>
{updatableCount > 0 && (
<button
className="pm-stat-action"
onClick={() => pluginsSocket?.emit('checkUpdates')}
>
Aktualisieren
<Trans i18nKey="admin_plugins.update_now"/>
</button>
)}
</div>
<div className="pm-stat">
<div className="pm-stat-label">Plugin-Quelle</div>
<div className="pm-stat-label"><Trans i18nKey="admin_plugins.source"/></div>
<div className="pm-stat-value pm-stat-value--sm">npm</div>
<div className="pm-stat-hint">registry.npmjs.org</div>
</div>
@ -204,7 +204,7 @@ export const HomePage = () => {
className="pm-btn pm-btn-ghost"
onClick={() => pluginsSocket?.emit('checkUpdates')}
>
<RefreshCw size={14}/> Nach Updates suchen
<RefreshCw size={14}/> <Trans i18nKey="admin_plugins.check_updates"/>
</button>
</div>
@ -216,9 +216,14 @@ export const HomePage = () => {
</div>
<div className="pm-installed-main">
<div className="pm-installed-title">
<span className="pm-mono">{plugin.name}</span>
<a
className="pm-mono pm-plugin-link"
href={`https://npmjs.com/${plugin.name}`}
target="_blank"
rel="noopener noreferrer"
>{plugin.name}</a>
{plugin.name === 'ep_etherpad-lite' && (
<span className="pm-tag pm-tag--core">Core</span>
<span className="pm-tag pm-tag--core"><Trans i18nKey="admin_plugins.tag_core"/></span>
)}
<span className="pm-tag pm-tag--ver">v{plugin.version}</span>
</div>
@ -231,7 +236,7 @@ export const HomePage = () => {
<IconButton
onClick={() => installPlugin(plugin.name)}
icon={<ArrowUpFromDot size={14}/>}
title="Update"
title={t('admin_plugins.update_tooltip')}
/>
) : (
<IconButton
@ -276,15 +281,29 @@ export const HomePage = () => {
setSearchParams({
...searchParams,
sortBy,
sortDir: sortBy === 'downloads' ? 'desc' : 'asc',
sortDir: 'asc',
})
}}
>
<option value="downloads">Beliebteste</option>
<option value="name">Name (AZ)</option>
<option value="version">Version</option>
<option value="last-updated">Zuletzt aktualisiert</option>
<option value="name">{t('admin_plugins.sort.name')}</option>
<option value="version">{t('admin_plugins.sort.version')}</option>
<option value="last-updated">{t('admin_plugins.sort.last_updated')}</option>
</select>
<button
className="pm-sort-dir"
onClick={() => setSearchParams({
...searchParams,
sortDir: searchParams.sortDir === 'asc' ? 'desc' : 'asc',
})}
title={t(searchParams.sortDir === 'asc'
? 'admin_plugins.sort_ascending'
: 'admin_plugins.sort_descending')}
aria-label={t(searchParams.sortDir === 'asc'
? 'admin_plugins.sort_ascending'
: 'admin_plugins.sort_descending')}
>
{searchParams.sortDir === 'asc' ? '↑' : '↓'}
</button>
</div>
</div>
@ -297,7 +316,6 @@ export const HomePage = () => {
<th><Trans i18nKey="admin_plugins.description"/></th>
<th style={{width: 62, textAlign: 'right'}}><Trans i18nKey="admin_plugins.version"/></th>
<th style={{width: 96}}><Trans i18nKey="admin_plugins.last-update"/></th>
<th style={{width: 68, textAlign: 'right'}}>Downloads</th>
<th style={{width: 108, textAlign: 'right'}}></th>
</tr>
</thead>
@ -308,10 +326,12 @@ export const HomePage = () => {
<div className="pm-cell-name">
<span className="pm-cell-icon"><Plug size={13}/></span>
<div className="pm-cell-title">
<span className="pm-mono">{plugin.name}</span>
{(plugin.downloads ?? 0) >= POPULAR_THRESHOLD && (
<span className="pm-tag pm-tag--popular">Beliebt</span>
)}
<a
className="pm-mono pm-plugin-link"
href={`https://npmjs.com/${plugin.name}`}
target="_blank"
rel="noopener noreferrer"
>{plugin.name}</a>
</div>
</div>
</td>
@ -332,9 +352,6 @@ export const HomePage = () => {
</td>
<td className="pm-num">{plugin.version}</td>
<td className="pm-cell-date">{plugin.time}</td>
<td className="pm-num">
{plugin.downloads != null ? fmtDownloads(plugin.downloads) : '—'}
</td>
<td className="pm-cell-action">
<button
className="pm-btn pm-btn-primary pm-btn--sm"

View file

@ -3,6 +3,7 @@ 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
@ -12,6 +13,7 @@ type Inputs = {
export const LoginScreen = ()=>{
const navigate = useNavigate()
const [passwordVisible, setPasswordVisible] = useState<boolean>(false)
const {t} = useTranslation()
const {
register,
@ -27,7 +29,7 @@ export const LoginScreen = ()=>{
if(!r.ok) {
useStore.getState().setToastState({
open: true,
title: "Login failed",
title: t('admin_login.failed'),
success: false
})
} else {
@ -40,21 +42,21 @@ export const LoginScreen = ()=>{
return <div className="login-background login-page">
<div className="login-box login-form">
<h1 className="login-title">Etherpad</h1>
<h1 className="login-title">{t('admin_login.title')}</h1>
<form className="login-inner-box input-control" onSubmit={handleSubmit(login)}>
<div>Username</div>
<div>{t('admin_login.username')}</div>
<input {...register('username', {
required: true
})} className="login-textinput input-control" type="text" placeholder="Username"/>
<div>Password</div>
})} className="login-textinput input-control" type="text" placeholder={t('admin_login.username')}/>
<div>{t('admin_login.password')}</div>
<span className="icon-input">
<input {...register('password', {
required: true
})} className="login-textinput" type={passwordVisible?"text":"password"} placeholder="Password"/>
})} className="login-textinput" type={passwordVisible?"text":"password"} placeholder={t('admin_login.password')}/>
{passwordVisible? <Eye onClick={()=>setPasswordVisible(!passwordVisible)}/> :
<EyeOff onClick={()=>setPasswordVisible(!passwordVisible)}/>}
</span>
<input type="submit" value="Login" className="login-button"/>
<input type="submit" value={t('admin_login.submit')} className="login-button"/>
</form>
</div>
</div>

View file

@ -6,47 +6,62 @@ import {useDebounce} from "../utils/useDebounce.ts";
import * as Dialog from "@radix-ui/react-dialog";
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";
type PadCreateProps = { padName: string }
type FilterId = 'all' | 'active' | 'recent' | 'empty' | 'stale'
const PAD_FILTERS: {id: FilterId, label: string}[] = [
{id: 'all', label: 'Alle'},
{id: 'active', label: 'Aktiv'},
{id: 'recent', label: 'Diese Woche'},
{id: 'empty', label: 'Leer'},
{id: 'stale', label: 'Veraltet (>1J)'},
]
const PAD_FILTER_IDS: FilterId[] = ['all', 'active', 'recent', 'empty', 'stale']
const isRecent = (ts: number) => (Date.now() - ts) < 86_400_000 * 7
const isStale = (ts: number) => (Date.now() - ts) > 86_400_000 * 365
function relativeTime(ts: number): string {
function relativeTime(t: TFunction, ts: number): string {
const d = (Date.now() - ts) / 1000
if (d < 60) return 'gerade eben'
if (d < 3600) return `vor ${Math.floor(d / 60)} Min`
if (d < 86400) return `vor ${Math.floor(d / 3600)} Std`
if (d < 86400 * 7) return `vor ${Math.floor(d / 86400)} Tagen`
if (d < 86400 * 30) return `vor ${Math.floor(d / 86400 / 7)} Wo`
if (d < 86400 * 365) return `vor ${Math.floor(d / 86400 / 30)} Mon`
return `vor ${Math.floor(d / 86400 / 365)} J`
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(ts: number): string {
function fmtDate(locale: string, ts: number): string {
const d = new Date(ts)
return (
d.toLocaleDateString('de-DE', {day: '2-digit', month: 'short', year: 'numeric'}) +
d.toLocaleDateString(locale, {day: '2-digit', month: 'short', year: 'numeric'}) +
' · ' +
d.toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'})
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<PadSearchQuery>({
offset: 0, limit: 12, pattern: '', sortBy: 'lastEdited', ascending: false,
})
const {t} = useTranslation()
const {t, i18n} = useTranslation()
const locale = sanitizeLocale(i18n.resolvedLanguage ?? i18n.language)
const [searchTerm, setSearchTerm] = useState('')
const [filter, setFilter] = useState<FilterId>('all')
const [selected, setSelected] = useState<Set<string>>(new Set())
@ -147,8 +162,8 @@ export const PadPage = () => {
<Dialog.Content className="dialog-confirm-content">
<div>{t('ep_admin_pads:ep_adminpads2_confirm', {padID: padToDelete})}</div>
<div className="settings-button-bar">
<button onClick={() => setDeleteDialog(false)}>Abbrechen</button>
<button onClick={() => { deletePad(padToDelete); setDeleteDialog(false) }}>OK</button>
<button onClick={() => setDeleteDialog(false)}><Trans i18nKey="admin_pads.cancel"/></button>
<button onClick={() => { deletePad(padToDelete); setDeleteDialog(false) }}>{t('admin_pads.confirm_button')}</button>
</div>
</Dialog.Content>
</Dialog.Portal>
@ -158,9 +173,9 @@ export const PadPage = () => {
<Dialog.Portal>
<Dialog.Overlay className="dialog-confirm-overlay"/>
<Dialog.Content className="dialog-confirm-content">
<div>Fehler: {errorText}</div>
<div>{t('admin_pads.error_prefix')}: {errorText}</div>
<div className="settings-button-bar">
<button onClick={() => setErrorText(null)}>OK</button>
<button onClick={() => setErrorText(null)}>{t('admin_pads.confirm_button')}</button>
</div>
</Dialog.Content>
</Dialog.Portal>
@ -177,7 +192,7 @@ export const PadPage = () => {
<label><Trans i18nKey="ep_admin_pads:ep_adminpads2_padname"/></label>
<input {...register('padName', {required: true})}/>
</div>
<input type="submit" value={t('admin_settings.createPad')} className="login-button"/>
<input type="submit" value={t('admin_settings.create_pad')} className="login-button"/>
</form>
</Dialog.Content>
</Dialog.Portal>
@ -186,13 +201,13 @@ export const PadPage = () => {
{/* ── Page header ── */}
<div className="pm-header">
<div>
<div className="pm-crumbs">Admin <span className="pm-crumbs-sep"></span> Pads</div>
<div className="pm-crumbs">Admin <span className="pm-crumbs-sep"></span> <Trans i18nKey="ep_admin_pads:ep_adminpads2_manage-pads"/></div>
<h1 className="pm-title"><Trans i18nKey="ep_admin_pads:ep_adminpads2_manage-pads"/></h1>
<p className="pm-subtitle">Übersicht aller Pads dieser Etherpad-Instanz. Suchen, aufräumen, öffnen.</p>
<p className="pm-subtitle"><Trans i18nKey="admin_pads.subtitle"/></p>
</div>
<div className="pm-header-actions">
<button className="pm-btn pm-btn-ghost" onClick={() => settingsSocket?.emit('padLoad', searchParams)}>
<RefreshCw size={14}/> Aktualisieren
<RefreshCw size={14}/> <Trans i18nKey="admin_pads.refresh"/>
</button>
<button className="pm-btn pm-btn-primary" onClick={() => setCreatePadDialogOpen(true)}>
<PlusIcon size={14}/> <Trans i18nKey="index.newPad"/>
@ -203,27 +218,29 @@ export const PadPage = () => {
{/* ── Stats ── */}
<div className="pm-stats">
<div className="pm-stat pm-stat--primary">
<div className="pm-stat-label">Pads gesamt</div>
<div className="pm-stat-label"><Trans i18nKey="admin_pads.stats.total"/></div>
<div className="pm-stat-value">{pads?.total ?? '—'}</div>
<div className="pm-stat-hint">{activeCount > 0 ? `${activeCount} gerade aktiv` : 'Keine aktiven Nutzer'}</div>
<div className="pm-stat-hint">{activeCount > 0
? t('admin_pads.stats.users_active', {count: activeCount})
: t('admin_pads.stats.no_active_users')}</div>
</div>
<div className="pm-stat">
<div className="pm-stat-label">Aktive Nutzer</div>
<div className="pm-stat-label"><Trans i18nKey="admin_pads.stats.active_users"/></div>
<div className="pm-stat-value">{totalUsers}</div>
<div className="pm-stat-hint">über alle Pads hinweg</div>
<div className="pm-stat-hint"><Trans i18nKey="admin_pads.stats.across_pads"/></div>
</div>
<div className={`pm-stat${emptyCount > 0 ? ' pm-stat--warn' : ''}`}>
<div className="pm-stat-label">Leere Pads</div>
<div className="pm-stat-label"><Trans i18nKey="admin_pads.stats.empty_pads"/></div>
<div className="pm-stat-value">{emptyCount}</div>
<div className="pm-stat-hint">0 Revisionen</div>
<div className="pm-stat-hint"><Trans i18nKey="admin_pads.stats.revisions_zero"/></div>
{emptyCount > 0 && (
<button className="pm-stat-action" onClick={() => setFilter('empty')}>Anzeigen </button>
<button className="pm-stat-action" onClick={() => setFilter('empty')}>{t('admin_pads.show')} </button>
)}
</div>
<div className="pm-stat">
<div className="pm-stat-label">Letzte Aktivität</div>
<div className="pm-stat-label"><Trans i18nKey="admin_pads.stats.last_activity"/></div>
<div className="pm-stat-value pm-stat-value--sm">
{lastActivity ? relativeTime(lastActivity) : '—'}
{lastActivity ? relativeTime(t, lastActivity) : '—'}
</div>
<div className="pm-stat-hint">{pads?.results?.[0]?.padName ?? ''}</div>
</div>
@ -232,7 +249,7 @@ export const PadPage = () => {
{/* ── Pads section ── */}
<section className="pm-section">
<div className="pm-section-header">
<h2>Alle Pads</h2>
<h2><Trans i18nKey="admin_pads.all_pads"/></h2>
<span className="pm-count-badge">{filteredResults.length}</span>
<div className="pm-spacer"/>
<div className="pm-toolbar">
@ -254,22 +271,38 @@ export const PadPage = () => {
onChange={e => setSearchParams({
...searchParams,
sortBy: e.target.value,
ascending: e.target.value === 'padName',
// Keep current direction when only the column changes; the
// ↑/↓ button below is the sole control for direction.
})}
>
<option value="lastEdited">Zuletzt bearbeitet</option>
<option value="padName">Name (AZ)</option>
<option value="userCount">Nutzer</option>
<option value="revisionNumber">Revisionen</option>
<option value="lastEdited">{t('ep_admin_pads:ep_adminpads2_last-edited')}</option>
<option value="padName">{t('admin_pads.sort.name')}</option>
<option value="userCount">{t('admin_pads.sort.user_count')}</option>
<option value="revisionNumber">{t('admin_pads.sort.revision_number')}</option>
</select>
<button
className="pm-sort-dir"
onClick={() => setSearchParams({
...searchParams,
ascending: !searchParams.ascending,
})}
title={t(searchParams.ascending
? 'admin_plugins.sort_ascending'
: 'admin_plugins.sort_descending')}
aria-label={t(searchParams.ascending
? 'admin_plugins.sort_ascending'
: 'admin_plugins.sort_descending')}
>
{searchParams.ascending ? '↑' : '↓'}
</button>
</div>
</div>
{/* Filter chips */}
<div className="pm-chips">
{PAD_FILTERS.map(f => (
<button key={f.id} className={`pm-chip${filter === f.id ? ' is-on' : ''}`} onClick={() => setFilter(f.id)}>
{f.label}
{PAD_FILTER_IDS.map(id => (
<button key={id} className={`pm-chip${filter === id ? ' is-on' : ''}`} onClick={() => setFilter(id)}>
{t(`admin_pads.filter.${id}`)}
</button>
))}
</div>
@ -277,21 +310,21 @@ export const PadPage = () => {
{/* Bulk bar */}
{selected.size > 0 && (
<div className="pm-bulk">
<span className="pm-bulk-count">{selected.size} ausgewählt</span>
<span className="pm-bulk-count">{t('admin_pads.selected_count', {count: selected.size})}</span>
<div className="pm-spacer"/>
<button className="pm-btn pm-btn-ghost" onClick={() => {
selected.forEach(name => cleanupPad(name))
setSelected(new Set())
}}>
<History size={14}/> Historie aufräumen
<History size={14}/> <Trans i18nKey="admin_pads.bulk.cleanup_history"/>
</button>
<button className="pm-btn pm-btn-danger" onClick={() => {
selected.forEach(name => deletePad(name))
setSelected(new Set())
}}>
<Trash2 size={14}/> Löschen
<Trash2 size={14}/> <Trans i18nKey="admin_pads.bulk.delete"/>
</button>
<button className="pm-btn pm-btn-icon" onClick={() => setSelected(new Set())} title="Auswahl aufheben">
<button className="pm-btn pm-btn-icon" onClick={() => setSelected(new Set())} title={t('admin_pads.bulk.clear_selection')}>
<X size={14}/>
</button>
</div>
@ -307,11 +340,11 @@ export const PadPage = () => {
<input type="checkbox" checked={allSelected} onChange={toggleAll}/>
</label>
</th>
<th>Pad</th>
<th style={{width: 100, textAlign: 'center'}}>Nutzer</th>
<th style={{width: 110, textAlign: 'right'}}>Revisionen</th>
<th style={{width: 210}}>Zuletzt bearbeitet</th>
<th style={{width: 170, textAlign: 'right'}}>Aktion</th>
<th><Trans i18nKey="admin_pads.col.pad"/></th>
<th style={{width: 100, textAlign: 'center'}}><Trans i18nKey="admin_pads.col.users"/></th>
<th style={{width: 110, textAlign: 'right'}}><Trans i18nKey="admin_pads.col.revisions"/></th>
<th style={{width: 210}}><Trans i18nKey="ep_admin_pads:ep_adminpads2_last-edited"/></th>
<th style={{width: 170, textAlign: 'right'}}><Trans i18nKey="ep_admin_pads:ep_adminpads2_action"/></th>
</tr>
</thead>
<tbody>
@ -333,7 +366,9 @@ export const PadPage = () => {
<div>
<div className="pm-pad-title">{pad.padName}</div>
<div className="pm-pad-sub">
{isEmpty ? 'leer · noch nie bearbeitet' : `${pad.revisionNumber} Revisionen`}
{isEmpty
? t('admin_pads.empty_never_edited')
: t('admin_pads.revisions_count', {count: pad.revisionNumber})}
</div>
</div>
</div>
@ -345,11 +380,11 @@ export const PadPage = () => {
<span className="pm-users-pill is-muted">0</span>
)}
</td>
<td className="pm-num">{pad.revisionNumber.toLocaleString('de-DE')}</td>
<td className="pm-num">{pad.revisionNumber.toLocaleString(locale)}</td>
<td>
<div className="pm-time">
<span className="pm-time-rel">{relativeTime(pad.lastEdited)}</span>
<span className="pm-time-abs">{fmtDate(pad.lastEdited)}</span>
<span className="pm-time-rel">{relativeTime(t, pad.lastEdited)}</span>
<span className="pm-time-abs">{fmtDate(locale, pad.lastEdited)}</span>
</div>
</td>
<td className="pm-cell-action">
@ -372,7 +407,7 @@ export const PadPage = () => {
className="pm-btn pm-btn-primary pm-btn--sm"
onClick={() => window.open(`../../p/${pad.padName}`, '_blank')}
>
<Eye size={13}/> Öffnen
<Eye size={13}/> <Trans i18nKey="admin_pads.open"/>
</button>
</div>
</td>
@ -385,7 +420,7 @@ export const PadPage = () => {
) : (
<div className="pm-empty">
<div className="pm-empty-icon"></div>
<div className="pm-empty-title">Keine Pads gefunden</div>
<div className="pm-empty-title"><Trans i18nKey="ep_admin_pads:ep_adminpads2_no-results"/></div>
</div>
)}
@ -400,7 +435,7 @@ export const PadPage = () => {
setSearchParams({...searchParams, offset: p * searchParams.limit})
}}
>
<ChevronLeft size={14}/> Zurück
<ChevronLeft size={14}/> <Trans i18nKey="admin_pads.pagination.previous"/>
</button>
<span className="pm-pagination-info">{currentPage + 1} / {pages || 1}</span>
<button
@ -412,7 +447,7 @@ export const PadPage = () => {
setSearchParams({...searchParams, offset: p * searchParams.limit})
}}
>
Weiter <ChevronRight size={14}/>
<Trans i18nKey="admin_pads.pagination.next"/> <ChevronRight size={14}/>
</button>
</div>
</section>

View file

@ -4,7 +4,6 @@ export type PluginDef = {
version: string,
time: string,
official: boolean,
downloads?: number,
/**
* `@feature:*` Playwright tags for core specs the plugin intentionally
* disables. See doc/PLUGIN_FEATURE_DISABLES.md. May be undefined for
@ -28,7 +27,7 @@ export type SearchParams = {
searchTerm: string,
offset: number,
limit: number,
sortBy: 'name'|'version'|'last-updated'|'downloads',
sortBy: 'name'|'version'|'last-updated',
sortDir: 'asc'|'desc'
}

View file

@ -1,12 +1,13 @@
import {useStore} from "../store/store.ts";
import {isJSONClean, cleanComments} from "../utils/utils.ts";
import {Trans} from "react-i18next";
import {Trans, useTranslation} from "react-i18next";
import {IconButton} from "../components/IconButton.tsx";
import {RotateCw, Save} from "lucide-react";
export const SettingsPage = ()=>{
const settingsSocket = useStore(state=>state.settingsSocket)
const settings = cleanComments(useStore(state=>state.settings))
const {t} = useTranslation()
return <div className="settings-page">
<h1><Trans i18nKey="admin_settings.current"/></h1>
@ -21,13 +22,13 @@ export const SettingsPage = ()=>{
settingsSocket!.emit('saveSettings', settings!);
useStore.getState().setToastState({
open: true,
title: "Successfully saved settings",
title: t('admin_settings.saved_success'),
success: true
})
} else {
useStore.getState().setToastState({
open: true,
title: "Error saving settings",
title: t('admin_settings.save_error'),
success: false
})
}

View file

@ -3,6 +3,7 @@ import {SendHorizonal} from 'lucide-react'
import {useStore} from "../store/store.ts";
import * as Switch from '@radix-ui/react-switch';
import {ShoutType} from "../components/ShoutType.ts";
import {Trans, useTranslation} from "react-i18next";
export const ShoutPage = ()=>{
const [totalUsers, setTotalUsers] = useState(0);
@ -11,6 +12,7 @@ export const ShoutPage = ()=>{
const socket = useStore(state => state.settingsSocket);
const pluginSocket = useStore(state => state.pluginsSocket);
const [shouts, setShouts] = useState<ShoutType[]>([]);
const {t} = useTranslation()
useEffect(() => {
@ -42,8 +44,8 @@ export const ShoutPage = ()=>{
return (
<div>
<h1>Communication</h1>
{totalUsers > 0 && <p>There {totalUsers>1?"are":"is"} currently {totalUsers} user{totalUsers>1?"s":""} online</p>}
<h1><Trans i18nKey="admin.shout"/></h1>
{totalUsers > 0 && <p>{t('admin_shout.online', {count: totalUsers})}</p>}
<div style={{height: '80vh', display: 'flex', flexDirection: 'column'}}>
<div style={{flexGrow: 1, backgroundColor: 'white', overflowY: "auto"}}>
{
@ -66,7 +68,7 @@ export const ShoutPage = ()=>{
e.preventDefault()
sendMessage()
}} className="send-message search-field" style={{display: 'flex', gap: '10px'}}>
<Switch.Root title="Change sticky message" className="SwitchRoot" checked={sticky}
<Switch.Root title={t('admin_shout.sticky_toggle')} className="SwitchRoot" checked={sticky}
onCheckedChange={() => {
setSticky(!sticky);
}}>

View file

@ -1,6 +0,0 @@
export const determineSorting = (sortBy: string, ascending: boolean, currentSymbol: string) => {
if (sortBy === currentSymbol) {
return ascending ? 'sort up' : 'sort down';
}
return 'sort none';
}

View file

@ -1,14 +1,71 @@
{
"admin.page-title": "Admin Dashboard - Etherpad",
"admin.loading": "Loading…",
"admin.toggle_sidebar": "Toggle sidebar",
"admin.shout": "Communication",
"admin_shout.online_one": "There is currently {{count}} user online",
"admin_shout.online_other": "There are currently {{count}} users online",
"admin_shout.sticky_toggle": "Change sticky message",
"admin_login.title": "Etherpad",
"admin_login.username": "Username",
"admin_login.password": "Password",
"admin_login.submit": "Login",
"admin_login.failed": "Login failed",
"admin_pads.all_pads": "All pads",
"admin_pads.bulk.cleanup_history": "Clean up history",
"admin_pads.bulk.clear_selection": "Clear selection",
"admin_pads.bulk.delete": "Delete",
"admin_pads.cancel": "Cancel",
"admin_pads.col.pad": "Pad",
"admin_pads.col.revisions": "Revisions",
"admin_pads.col.users": "Users",
"admin_pads.confirm_button": "OK",
"admin_pads.empty_never_edited": "empty · never edited",
"admin_pads.error_prefix": "Error",
"admin_pads.filter.active": "Active",
"admin_pads.filter.all": "All",
"admin_pads.filter.empty": "Empty",
"admin_pads.filter.recent": "This week",
"admin_pads.filter.stale": "Stale (>1y)",
"admin_pads.open": "Open",
"admin_pads.pagination.next": "Next",
"admin_pads.pagination.previous": "Previous",
"admin_pads.refresh": "Refresh",
"admin_pads.relative.days": "{{count}}d ago",
"admin_pads.relative.hours": "{{count}}h ago",
"admin_pads.relative.just_now": "just now",
"admin_pads.relative.minutes": "{{count}}m ago",
"admin_pads.relative.months": "{{count}}mo ago",
"admin_pads.relative.weeks": "{{count}}w ago",
"admin_pads.relative.years": "{{count}}y ago",
"admin_pads.revisions_count": "{{count}} revisions",
"admin_pads.selected_count": "{{count}} selected",
"admin_pads.show": "Show",
"admin_pads.sort.name": "Name (AZ)",
"admin_pads.sort.revision_number": "Revisions",
"admin_pads.sort.user_count": "Users",
"admin_pads.stats.across_pads": "across all pads",
"admin_pads.stats.active_users": "Active users",
"admin_pads.stats.empty_pads": "Empty pads",
"admin_pads.stats.last_activity": "Last activity",
"admin_pads.stats.no_active_users": "No active users",
"admin_pads.stats.revisions_zero": "0 revisions",
"admin_pads.stats.total": "Total pads",
"admin_pads.stats.users_active": "{{count}} currently active",
"admin_pads.subtitle": "Overview of all pads on this Etherpad instance. Search, clean up, open.",
"admin_plugins": "Plugin manager",
"admin_plugins.available": "Available plugins",
"admin_plugins.available_not-found": "No plugins found.",
"admin_plugins.available_fetching": "Fetching…",
"admin_plugins.available_install.value": "Install",
"admin_plugins.available_search.placeholder": "Search for plugins to install",
"admin_plugins.check_updates": "Check for updates",
"admin_plugins.core_count": "{{count}} core",
"admin_plugins.crumbs": "Plugins",
"admin_plugins.description": "Description",
"admin_plugins.disables.label": "Disables:",
"admin_plugins.disables.warning_title": "This plugin intentionally removes the listed Etherpad features.",
"admin_plugins.error_retrieving": "Error retrieving plugins",
"admin_plugins.installed": "Installed plugins",
"admin_plugins.installed_fetching": "Fetching installed plugins…",
"admin_plugins.installed_nothing": "You haven't installed any plugins yet.",
@ -16,24 +73,53 @@
"admin_plugins.last-update": "Last update",
"admin_plugins.name": "Name",
"admin_plugins.page-title": "Plugin manager - Etherpad",
"admin_plugins.reload_catalog": "Reload catalog",
"admin_plugins.search_npm": "Search on npm",
"admin_plugins.sort_ascending": "Sort ascending",
"admin_plugins.sort_descending": "Sort descending",
"admin_plugins.sort.last_updated": "Last updated",
"admin_plugins.sort.name": "Name (AZ)",
"admin_plugins.sort.version": "Version",
"admin_plugins.source": "Plugin source",
"admin_plugins.subtitle": "Install, update, and remove Etherpad plugins. Changes require a server restart.",
"admin_plugins.tag_core": "Core",
"admin_plugins.update_tooltip": "Update",
"admin_plugins.updates_available": "Updates available",
"admin_plugins.update_now": "Update",
"admin_plugins.version": "Version",
"admin_plugins_info": "Troubleshooting information",
"admin_plugins_info.bindings_label": "{{count}} bindings",
"admin_plugins_info.copy_diagnostics": "Copy diagnostics",
"admin_plugins_info.copy_value": "Copy {{label}}",
"admin_plugins_info.git_sha": "Git SHA",
"admin_plugins_info.hook_bindings": "Hook bindings",
"admin_plugins_info.hooks": "Installed hooks",
"admin_plugins_info.hooks_client": "Client-side hooks",
"admin_plugins_info.hooks_server": "Server-side hooks",
"admin_plugins_info.no_hooks": "No hooks found",
"admin_plugins_info.parts": "Installed parts",
"admin_plugins_info.plugins": "Installed plugins",
"admin_plugins_info.page-title": "Plugin information - Etherpad",
"admin_plugins_info.search_placeholder": "Search hook or part…",
"admin_plugins_info.subtitle": "System diagnostics: installed version, registered parts and hooks.",
"admin_plugins_info.tab_client": "Client",
"admin_plugins_info.tab_server": "Server",
"admin_plugins_info.up_to_date": "Up to date",
"admin_plugins_info.update_available": "Update available: {{version}}",
"admin_plugins_info.version": "Etherpad version",
"admin_plugins_info.version_latest": "Latest available version",
"admin_plugins_info.version_number": "Version number",
"admin_settings": "Settings",
"admin_settings.create_pad": "Create pad",
"admin_settings.current": "Current configuration",
"admin_settings.current_example-devel": "Example development settings template",
"admin_settings.current_example-prod": "Example production settings template",
"admin_settings.current_restart.value": "Restart Etherpad",
"admin_settings.current_save.value": "Save Settings",
"admin_settings.invalid_json": "Invalid JSON",
"admin_settings.page-title": "Settings - Etherpad",
"admin_settings.save_error": "Error saving settings",
"admin_settings.saved_success": "Successfully saved settings",
"update.banner.title": "Update available",
"update.banner.body": "Etherpad {{latest}} is available (you are running {{current}}).",
@ -46,6 +132,9 @@
"update.page.tier": "Update tier",
"update.page.changelog": "Changelog",
"update.page.up_to_date": "You are running the latest version.",
"update.page.disabled": "Update checks are disabled (updates.tier = \"off\").",
"update.page.unauthorized": "You are not authorised to view update status.",
"update.page.error": "Could not load update status (status {{status}}).",
"update.badge.severe": "Etherpad on this server is severely outdated. Tell your admin.",
"update.badge.vulnerable": "Etherpad on this server is running a version with known security issues. Tell your admin.",
"update.page.apply": "Apply update",

View file

@ -0,0 +1,138 @@
'use strict';
// Source-level lint: every user-facing string in admin/src/pages and
// admin/src/App.tsx must go through react-i18next (t()/<Trans>). PR #7716
// shipped the new admin UI with ~50+ literal German strings, which produced
// a French/English/German salad for non-DE users (issue #7735). This test
// catches that class of regression in CI without needing a live server —
// the matching Playwright spec at admin-spec/admini18n.spec.ts exercises
// the rendered output.
import {readFileSync} from 'fs';
import {join} from 'path';
import {describe, it, expect} from 'vitest';
const repoRoot = join(__dirname, '..', '..', '..', '..');
const read = (rel: string) => readFileSync(join(repoRoot, rel), 'utf8');
// Stripped of code-fence quirks, but JSX text nodes and quoted string props
// are caught by the same heuristic: a German word adjacent to user-facing
// JSX/JS context. The list is short and targets the words that landed in
// PR #7716; expand when new locale-specific words leak in.
const FORBIDDEN_LITERALS = [
'verfügbar', 'Diagnose', 'kopieren', 'Aktualisieren', 'Zurück', 'Weiter',
'Beliebt', 'Beliebteste', 'Übersicht', 'aufräumen', 'Löschen', 'Öffnen',
'gefunden', 'Veraltet', 'Diese Woche', 'noch nie', 'gerade eben',
'gerade aktiv', 'Aktive Nutzer', 'Hook-Bindings', 'Pads gesamt',
'Letzte Aktivität', 'Plugin-Quelle', 'Katalog neu laden', 'Auf npm suchen',
'Nach Updates suchen', 'Auf dem neuesten', 'System-Diagnose',
'Keine Pads', 'Keine Hooks', 'Auswahl aufheben', 'ausgewählt',
'Hook oder Teil',
];
// Files we audit. Keep tight — generated/vendor code is excluded.
const AUDITED = [
'admin/src/App.tsx',
'admin/src/pages/HomePage.tsx',
'admin/src/pages/HelpPage.tsx',
'admin/src/pages/PadPage.tsx',
'admin/src/pages/SettingsPage.tsx',
'admin/src/pages/LoginScreen.tsx',
'admin/src/pages/UpdatePage.tsx',
'admin/src/pages/ShoutPage.tsx',
];
describe('admin i18n source lint', () => {
for (const rel of AUDITED) {
it(`${rel} contains no hardcoded German user-facing strings`, () => {
const src = read(rel);
const hits: string[] = [];
for (const word of FORBIDDEN_LITERALS) {
if (src.includes(word)) hits.push(word);
}
expect(hits, `${rel} contains forbidden literals: ${hits.join(', ')}`)
.toEqual([]);
});
}
it('PadPage no longer hardcodes de-DE locale formatters', () => {
const src = read('admin/src/pages/PadPage.tsx');
expect(src.includes("'de-DE'"), 'PadPage still calls toLocale*("de-DE", ...)')
.toBe(false);
});
it('PadPage sanitises i18n.language before passing to Intl', () => {
// Qodo finding: i18n.language flows from user-controlled ?lng= and a
// malformed tag would throw RangeError in toLocale*(). Guard the
// sanitiser pattern so a future refactor cannot quietly remove it.
const src = read('admin/src/pages/PadPage.tsx');
expect(src.includes('sanitizeLocale'),
'PadPage no longer wraps i18n.language in sanitizeLocale()').toBe(true);
expect(src.includes('Intl.DateTimeFormat.supportedLocalesOf'),
'sanitizeLocale no longer validates via Intl.supportedLocalesOf').toBe(true);
});
it('PluginDef no longer exposes the dead downloads field', () => {
// Backend (src/static/js/pluginfw/installer.ts::search) never populates
// downloads. PR #7716 wired it through the frontend anyway, producing a
// dead Downloads column, "Most popular" default sort, and a "Popular"
// tag that never appeared. Guard the cleanup.
const src = read('admin/src/pages/Plugin.ts');
expect(src.match(/downloads\??:\s*number/),
'PluginDef still declares downloads — dead UI field').toBeNull();
expect(src.includes("'downloads'"),
"SearchParams['sortBy'] still includes 'downloads'").toBe(false);
});
it('orphan modules from pre-rework admin are gone', () => {
// SearchField.tsx and sorting.ts were imported by the pre-rework admin
// pages but the rework dropped both. Delete-then-grep here so a future
// unrelated import accidentally re-adding them fails review.
const fs = require('fs');
const join = require('path').join;
expect(fs.existsSync(join(repoRoot, 'admin/src/components/SearchField.tsx')),
'admin/src/components/SearchField.tsx is dead code from pre-rework admin').toBe(false);
expect(fs.existsSync(join(repoRoot, 'admin/src/utils/sorting.ts')),
'admin/src/utils/sorting.ts is dead code from pre-rework admin').toBe(false);
});
it('PadPage sort dropdown is paired with a direction toggle', () => {
// PR #7716 hardcoded `ascending: e.target.value === 'padName'`, leaving
// no way to invert sort direction. The fix is an explicit ↑/↓ button.
const src = read('admin/src/pages/PadPage.tsx');
expect(src.includes('pm-sort-dir'),
'PadPage no longer renders the .pm-sort-dir direction toggle').toBe(true);
expect(src.includes("ascending: e.target.value === 'padName'"),
'PadPage still hardcodes ascending direction in onChange').toBe(false);
});
it('UpdatePage referenced keys exist in en.json', () => {
const en = JSON.parse(read('src/locales/en.json')) as Record<string, string>;
// Keys added in this PR — guard against accidental rename/typo.
for (const k of [
'admin.loading', 'admin.toggle_sidebar', 'admin.shout',
'admin_login.failed', 'admin_login.username', 'admin_login.password',
'admin_login.submit', 'admin_login.title',
'admin_pads.subtitle', 'admin_pads.refresh', 'admin_pads.cancel',
'admin_pads.relative.just_now',
'admin_pads.relative.minutes', 'admin_pads.relative.years',
'admin_pads.filter.all', 'admin_pads.filter.active',
'admin_pads.filter.recent', 'admin_pads.filter.empty',
'admin_pads.filter.stale',
'admin_plugins.subtitle', 'admin_plugins.reload_catalog',
'admin_plugins.search_npm', 'admin_plugins.updates_available',
'admin_plugins.update_tooltip',
'admin_plugins.sort_ascending', 'admin_plugins.sort_descending',
'admin_plugins.error_retrieving',
'admin_plugins_info.subtitle', 'admin_plugins_info.copy_diagnostics',
'admin_plugins_info.up_to_date', 'admin_plugins_info.update_available',
'admin_plugins_info.git_sha', 'admin_plugins_info.no_hooks',
'admin_plugins_info.tab_server', 'admin_plugins_info.tab_client',
'admin_settings.saved_success', 'admin_settings.save_error',
'admin_settings.create_pad', 'admin_settings.invalid_json',
'update.page.disabled', 'update.page.unauthorized', 'update.page.error',
]) {
expect(en[k], `Missing en.json key: ${k}`).toBeDefined();
}
});
});

View file

@ -1,34 +1,128 @@
import {expect, test} from "@playwright/test";
import {expect, test, Page} from "@playwright/test";
import {loginToAdmin} from "../helper/adminhelper";
// Regression coverage for https://github.com/ether/etherpad/issues/7586
// and https://github.com/ether/etherpad/issues/7735.
//
// 2.7.0 shipped with the admin SPA's locale files copied to a wrong
// build path; fetches for them silently fell back to the SPA's
// index.html, JSON.parse failed, and every <Trans> rendered as its
// raw key. None of the existing admin specs asserted on translated
// strings, so the regression slipped through. We now bundle the
// translations through Vite (import.meta.glob) — these tests pin the
// rendered behaviour rather than the file path so any future
// loading-mechanism change is covered too.
test.beforeEach(async ({ page })=>{
// raw key. The 2.7.3 "admin design rework" (#7716) then introduced ~50+
// hardcoded German literals across the admin pages, producing a
// French/English/German salad for non-DE users (#7735). We assert the
// rendered text in both English and German so a future regression that
// either (a) breaks locale loading or (b) re-introduces hardcoded copy
// will fail here rather than ship.
test.describe.configure({mode: 'serial'});
test.beforeEach(async ({page}) => {
await loginToAdmin(page, 'admin', 'changeme1');
});
const open = async (page: Page, path: string) => {
await page.goto(`http://localhost:9001${path}`);
};
test.describe('admin i18n', () => {
test('renders translated text on /admin (default English)', async ({page}) => {
await page.goto('http://localhost:9001/admin/');
await open(page, '/admin/');
// HomePage renders <h1><Trans i18nKey="admin_plugins"/></h1>. If
// translations fail to load, the visible text becomes the raw key
// "admin_plugins". Asserting on the translated form catches that.
await expect(page.locator('h1', { hasText: /^Plugin manager$/ }))
.toBeVisible({ timeout: 30000 });
await expect(page.getByText('admin_plugins', { exact: true })).toHaveCount(0);
await expect(page.locator('h1', {hasText: /^Plugin manager$/}))
.toBeVisible({timeout: 30000});
await expect(page.getByText('admin_plugins', {exact: true})).toHaveCount(0);
});
test('switches language to German via ?lng=de', async ({page}) => {
await page.goto('http://localhost:9001/admin/?lng=de');
await expect(page.locator('h1', { hasText: /^Pluginverwaltung$/ }))
.toBeVisible({ timeout: 30000 });
await open(page, '/admin/?lng=de');
await expect(page.locator('h1', {hasText: /^Pluginverwaltung$/}))
.toBeVisible({timeout: 30000});
});
// The strings below were hardcoded German in #7716 — these assertions
// pin the rendered output so a regression cannot pass review again.
test('HomePage subtitle + buttons translate (English)', async ({page}) => {
await open(page, '/admin/');
await expect(page.getByText(/Install, update, and remove Etherpad plugins/))
.toBeVisible({timeout: 30000});
await expect(page.getByRole('button', {name: /Reload catalog/})).toBeVisible();
await expect(page.getByRole('link', {name: /Search on npm/})).toBeVisible();
// German leakage check: no hardcoded German on an English page.
await expect(page.getByText(/Aktualisieren/)).toHaveCount(0);
await expect(page.getByText(/verfügbar/)).toHaveCount(0);
});
test('HomePage stats labels translate (English)', async ({page}) => {
await open(page, '/admin/');
await expect(page.getByText(/Updates available/, {exact: false})).toBeVisible();
await expect(page.getByText(/Plugin source/, {exact: false})).toBeVisible();
});
test('HomePage plugin names link to npmjs.com (restored regression)', async ({page}) => {
// PR #7716 dropped <a href="https://npmjs.com/..."> wrappers on the
// installed-plugin rows. Restored — assert at least the core plugin
// row has an external link to npm so the regression cannot return.
await open(page, '/admin/');
const link = page.locator('a.pm-plugin-link', {hasText: 'ep_etherpad-lite'});
await expect(link).toBeVisible({timeout: 30000});
await expect(link).toHaveAttribute('href', /npmjs\.com\/ep_etherpad-lite/);
await expect(link).toHaveAttribute('target', '_blank');
});
test('HomePage available-plugins toolbar has a sort-direction toggle', async ({page}) => {
// PR #7716 removed clickable sort headers and left a sort dropdown
// with no way to invert direction. Restored as a ↑/↓ button.
await open(page, '/admin/');
await expect(page.locator('button.pm-sort-dir')).toBeVisible({timeout: 30000});
});
test('PadPage filters + headers translate (English)', async ({page}) => {
await open(page, '/admin/pads');
// Wait for the page to actually render — pad load is async.
await page.waitForSelector('.pm-chips', {timeout: 30000});
// Filter chips (formerly "Alle", "Aktiv", "Diese Woche", "Leer", "Veraltet").
await expect(page.getByRole('button', {name: /^All$/})).toBeVisible();
await expect(page.getByRole('button', {name: /^Active$/})).toBeVisible();
await expect(page.getByRole('button', {name: /^This week$/})).toBeVisible();
await expect(page.getByRole('button', {name: /^Empty$/})).toBeVisible();
await expect(page.getByRole('button', {name: /Stale/})).toBeVisible();
await expect(page.getByText(/Total pads/)).toBeVisible();
await expect(page.getByText(/Active users/)).toBeVisible();
// German leakage check.
await expect(page.getByText(/Alle Pads/)).toHaveCount(0);
await expect(page.getByText(/Zurück/)).toHaveCount(0);
});
test('HelpPage tabs + status translate (English)', async ({page}) => {
await open(page, '/admin/help');
await page.waitForSelector('.pm-hv-num', {timeout: 30000});
await expect(page.getByRole('button', {name: /Copy diagnostics/})).toBeVisible();
// Server / Client hook tabs.
await expect(page.getByRole('button', {name: /^Server\s+\d+$/})).toBeVisible();
await expect(page.getByRole('button', {name: /^Client\s+\d+$/})).toBeVisible();
// German leakage.
await expect(page.getByText(/Diagnose kopieren/)).toHaveCount(0);
await expect(page.getByText(/Keine Hooks/)).toHaveCount(0);
});
test('Login screen labels translate (English)', async ({page}) => {
// Hit /admin/login directly without auth via a fresh context.
await page.context().clearCookies();
await open(page, '/admin/login');
await expect(page.getByPlaceholder('Username')).toBeVisible({timeout: 30000});
await expect(page.getByPlaceholder('Password')).toBeVisible();
await expect(page.locator('input[type="submit"][value="Login"]')).toBeVisible();
});
test('PadPage filter chips localised to German via ?lng=de', async ({page}) => {
await open(page, '/admin/pads?lng=de');
await page.waitForSelector('.pm-chips', {timeout: 30000});
// de.json may not yet have the new keys (translatewiki round-trips on its
// own cadence); falling back to English via i18next is acceptable. What
// matters is that the literal hardcoded German strings from #7716 are
// gone — i.e. nothing on this page comes from a hardcoded source.
await expect(page.getByText(/Plugin manager/)).toHaveCount(0);
});
});