From 451a178ed35eb464f2df802c53aec435a9f910a7 Mon Sep 17 00:00:00 2001 From: SamTV12345 <40429738+samtv12345@users.noreply.github.com> Date: Sun, 10 May 2026 17:42:17 +0200 Subject: [PATCH 1/2] chore: fixed admin design rework --- admin/package.json | 1 + admin/src/App.tsx | 165 +++-- admin/src/index.css | 1110 +++++++++++++++++++++++++++++++--- admin/src/pages/HelpPage.tsx | 271 +++++++-- admin/src/pages/HomePage.tsx | 551 +++++++++-------- admin/src/pages/PadPage.tsx | 617 +++++++++++-------- admin/src/pages/Plugin.ts | 6 +- 7 files changed, 2039 insertions(+), 682 deletions(-) diff --git a/admin/package.json b/admin/package.json index cd5cb440907..e906c119b19 100644 --- a/admin/package.json +++ b/admin/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "pnpm gen:api && vite", + "dev:only": "vite", "gen:api": "node scripts/gen-api.mjs", "build": "pnpm gen:api && tsc && vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", diff --git a/admin/src/App.tsx b/admin/src/App.tsx index 27d5a2ae367..a4aecedcc96 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -10,41 +10,32 @@ import {Cable, Construction, Crown, NotepadText, Wrench, PhoneCall, LucideMenu, 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 {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']}) pluginsSocket.on('connect', () => { useStore.getState().setPluginsSocket(pluginsSocket); }); - settingSocket.on('connect', () => { useStore.getState().setSettingsSocket(settingSocket); useStore.getState().setShowLoading(false) @@ -53,33 +44,21 @@ 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') settingSocket.connect(); }); settingSocket.on('settings', (settings) => { - /* Check whether the settings.json is authorized to be viewed */ if (settings.results === 'NOT_ALLOWED') { console.log('Not allowed to view settings.json') return; } - - /* Check to make sure the JSON is clean before proceeding */ - if (isJSONClean(settings.results)) { - setSettings(settings.results); - } else { - alert('Invalid JSON'); - } + if (isJSONClean(settings.results)) setSettings(settings.results); + else alert('Invalid JSON'); useStore.getState().setShowLoading(false); }); - settingSocket.on('saveprogress', (status) => { - console.log(status) - }) + settingSocket.on('saveprogress', (status) => console.log(status)) return () => { settingSocket.disconnect(); @@ -87,37 +66,99 @@ 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/index.css b/admin/src/index.css index 64eae3ccc4f..936aaf8401f 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,11 +53,8 @@ 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; @@ -86,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; -} - -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; + flex-direction: column; + overflow: hidden; } -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 { @@ -152,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 { @@ -342,34 +320,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 { @@ -377,10 +339,9 @@ pre { } div.menu { - height: auto; + height: 100vh; border-right: none; - --sidebar-width: 100%; - float: left; + width: 64px; } table { @@ -922,3 +883,988 @@ input, button, select, optgroup, textarea { .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; +} + +/* 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/pages/HelpPage.tsx b/admin/src/pages/HelpPage.tsx index 13454742096..7d649742d4c 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) - }); - - settingsSocket?.emit('help'); - }, [settingsSocket]); - - const renderHooks = (hooks:Record>) => { - return Object.keys(hooks).map((hookName, i) => { - return
-

{hookName}

-
    - {Object.keys(hooks[hookName]).map((hook, i) =>
  • {hook} -
      - {Object.keys(hooks[hookName][hook]).map((subHook, i) =>
    • {subHook}
    • )} -
    -
  • )} -
-
- }) + if (!settingsSocket) return + settingsSocket.on('reply:help', (data) => setHelpData(data)) + 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 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 + + 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]) + + const totalBindings = hooks.reduce((n, h) => n + h.parts.length, 0) + + const updateAvailable = helpData + ? helpData.epVersion.localeCompare(helpData.latestVersion, undefined, {numeric: true}) < 0 + : false + + 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
+

+

System-Diagnose: installierte Version, registrierte Teile und Hooks.

+
+
+ +
+
+ + {/* ── Version block ── */} +
+
+
+
{helpData.epVersion}
+
+ + {updateAvailable + ? `Update verfügbar: ${helpData.latestVersion}` + : 'Auf dem neuesten Stand'} +
+
+
+
+
+
{helpData.latestVersion}
+
+
+
Git SHA
+
+ {helpData.gitCommit} + +
+
+
+
+
{helpData.installedPlugins.length}
+
+
+
+
{helpData.installedParts.length}
+
+
+
Hook-Bindings
+
{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}} + + ) + })} +
+
+
+
- if (!helpData) return
+ {/* ── Hooks ── */} +
+
+

+ {filteredHooks.length} +
+
+
+ + +
+
+ + setQ(e.target.value)} + placeholder="Hook oder Teil suchen…" + /> + {q && } +
+
+
- return
-

-
-
-
{helpData?.epVersion}
-
-
{helpData.latestVersion}
-
Git sha
-
{helpData.gitCommit}
+ {filteredHooks.length > 0 ? ( +
+ {filteredHooks.map(h => ( +
+
+ {h.name} + {h.parts.length} Bindings +
+
+ {h.parts.map(p => ( + {p} + ))} +
+
+ ))} +
+ ) : ( +
+
+
Keine Hooks gefunden
+
+ )} +
-

-
    - {helpData.installedPlugins.map((plugin, i) =>
  • {plugin}
  • )} -
- -

-
    - {helpData.installedParts.map((part, i) =>
  • {part}
  • )} -
- -

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

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

- -
+ ) } diff --git a/admin/src/pages/HomePage.tsx b/admin/src/pages/HomePage.tsx index 244f3490571..ad4281e63fc 100644 --- a/admin/src/pages/HomePage.tsx +++ b/admin/src/pages/HomePage.tsx @@ -3,269 +3,344 @@ 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"; +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([]) - const installedPlugins = useStore(state=>state.installedPlugins) - const setInstalledPlugins = useStore(state=>state.setInstalledPlugins) + const pluginsSocket = useStore(state => state.pluginsSocket) + const [plugins, setPlugins] = useState([]) + const installedPlugins = useStore(state => state.installedPlugins) + const setInstalledPlugins = useStore(state => state.setInstalledPlugins) const [searchParams, setSearchParams] = useState({ offset: 0, limit: 99999, - sortBy: 'name', - sortDir: 'asc', - searchTerm: '' + sortBy: 'downloads', + sortDir: 'desc', + searchTerm: '', }) - - 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 [searchTerm, setSearchTerm] = useState('') + const {t} = useTranslation() + + const updatableCount = useMemo( + () => installedPlugins.filter(p => p.updatable).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 === 'downloads') { + return ((b.downloads ?? 0) - (a.downloads ?? 0)) * (dir * -1) } - - if(searchParams.sortBy === "last-updated"){ - if(searchParams.sortDir === "asc"){ - return a.time.localeCompare(b.time) - } - return b.time.localeCompare(a.time) + if (searchParams.sortBy === 'version') { + return a.version.localeCompare(b.version) * dir } - - - if (searchParams.sortBy === "name") { - if(searchParams.sortDir === "asc"){ - return a.name.localeCompare(b.name) - } - return b.name.localeCompare(a.name) + if (searchParams.sortBy === 'last-updated') { + return a.time.localeCompare(b.time) * dir } - return 0 + return a.name.localeCompare(b.name) * dir }) }, [plugins, searchParams]) - const sortedInstalledPlugins = useMemo(()=>{ - return useStore.getState().installedPlugins.sort((a, b)=>{ - - if(a.name < b.name){ - return -1 - } - if(a.name > b.name){ - return 1 - } - return 0 - }) - - } ,[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'); + useEffect(() => { + if (!pluginsSocket) return - // check for updates every 5mins - const interval = setInterval(() => { - pluginsSocket.emit('checkUpdates'); - }, 1000 * 60 * 5); - - return ()=>{ - clearInterval(interval) - } - }, [pluginsSocket]); + pluginsSocket.on('results:installed', (data: {installed: InstalledPlugin[]}) => { + setInstalledPlugins(data.installed) + }) + pluginsSocket.on('results:updatable', (data) => { + const updated = useStore.getState().installedPlugins.map(plugin => + data.updatable.includes(plugin.name) ? {...plugin, updatable: true} : plugin + ) + setInstalledPlugins(updated) + }) - 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]); + pluginsSocket.on('finished:install', () => { + pluginsSocket.emit('getInstalled') + }) - const uninstallPlugin = (pluginName: string)=>{ - pluginsSocket!.emit('uninstall', pluginName); - // Remove plugin - setInstalledPlugins(installedPlugins.filter(i=>i.name !== pluginName)) - } + pluginsSocket.on('finished:uninstall', () => { + console.log('Finished uninstall') + }) - const installPlugin = (pluginName: string)=>{ - pluginsSocket!.emit('install', pluginName); - setPlugins(plugins.filter(plugin=>plugin.name !== pluginName)) - } + pluginsSocket.on('connect', () => { + pluginsSocket.emit('getInstalled') + pluginsSocket.emit('search', searchParams) + }) - useDebounce(()=>{ - setSearchParams({ - ...searchParams, - offset: 0, - searchTerm: searchTerm - }) - }, 500, [searchTerm]) + pluginsSocket.emit('getInstalled') + const interval = setInterval(() => pluginsSocket.emit('checkUpdates'), 1000 * 60 * 5) + return () => clearInterval(interval) + }, [pluginsSocket]) - return
-

+ useEffect(() => { + if (!pluginsSocket) return + pluginsSocket.emit('search', searchParams) + pluginsSocket.on('results:search', (data: {results: PluginDef[]}) => { + setPlugins(data.results) + }) + pluginsSocket.on('results:searcherror', () => { + useStore.getState().setToastState({open: true, title: 'Error retrieving plugins', success: false}) + }) + }, [searchParams, pluginsSocket]) + + 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)) + } + + useDebounce(() => { + setSearchParams({...searchParams, offset: 0, searchTerm}) + }, 500, [searchTerm]) + + return ( +
+ + {/* ── Page header ────────────────────────────────────────────────── */} +
+
+
+ Admin Plugins +
+

{t('admin_plugins')}

+

+ Installiere, aktualisiere und entferne Etherpad-Plugins. + Änderungen erfordern einen Server-Neustart. +

+
+
+ + + Auf npm suchen + +
+
-

+ {/* ── Stats row ──────────────────────────────────────────────────── */} +
+
+
+
{installedPlugins.length}
+
Davon 1 Core
+
+
+
+
{plugins.length}
+
+
0 ? ' pm-stat--warn' : ''}`}> +
Updates verfügbar
+
{updatableCount}
+ {updatableCount > 0 && ( + + )} +
+
+
Plugin-Quelle
+
npm
+
registry.npmjs.org
+
+
- - - - - - - - - - {sortedInstalledPlugins.map((plugin, index) => { - return - - + {/* ── Installed plugins ──────────────────────────────────────────── */} +
+
+

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

+ {filteredInstallablePlugins.length} +
+
+
+ + setSearchTerm(e.target.value)} + placeholder={t('admin_plugins.available_search.placeholder')} + /> + {searchTerm && ( + + )} +
+ +
+
+ + {filteredInstallablePlugins.length > 0 ? ( +
+
{plugin.name}{plugin.version}
+ + + + + + + + + + + + {filteredInstallablePlugins.map(plugin => ( + - - })} - -
Downloads
- { - plugin.updatable ? - installPlugin(plugin.name)} icon={} title="Update"> - : } title={} onClick={() => uninstallPlugin(plugin.name)}/> - } +
+ +
+ {plugin.name} + {(plugin.downloads ?? 0) >= POPULAR_THRESHOLD && ( + Beliebt + )} +
+
- - -

- {setSearchTerm(v.target.value)}} placeholder={t('admin_plugins.available_search.placeholder')} value={searchTerm}/> - -
- - - - - - - - - - - - {(filteredInstallablePlugins.length > 0) ? - filteredInstallablePlugins.map((plugin) => { - return - - - - - - - }) - : - - } - -
{ - setSearchParams({ - ...searchParams, - sortBy: 'name', - sortDir: searchParams.sortDir === "asc"? "desc": "asc" - }) - }}> - { - setSearchParams({ - ...searchParams, - sortBy: 'version', - sortDir: searchParams.sortDir === "asc"? "desc": "asc" - }) - }}>{ - setSearchParams({ - ...searchParams, - sortBy: 'last-updated', - sortDir: searchParams.sortDir === "asc"? "desc": "asc" - }) - }}>
{plugin.name} - {plugin.description} - {plugin.disables && plugin.disables.length > 0 && ( -
- {' '} - {plugin.disables - .map((tag) => tag.replace(/^@feature:/, '')) - .join(', ')} -
- )} -
{plugin.version}{plugin.time} - } onClick={() => installPlugin(plugin.name)} title={}/> -
{searchTerm == '' ? : }
-
+ + {plugin.description} + {plugin.disables && plugin.disables.length > 0 && ( +
+ {' '} + {plugin.disables + .map(tag => tag.replace(/^@feature:/, '')) + .join(', ')} +
+ )} + + {plugin.version} + {plugin.time} + + {plugin.downloads != null ? fmtDownloads(plugin.downloads) : '—'} + + + + + + ))} + + +
+ ) : ( +
+
+
+ {searchTerm === '' + ? + : } +
+
+ )} +
+ ) } diff --git a/admin/src/pages/PadPage.tsx b/admin/src/pages/PadPage.tsx index cedef1157f5..92509a584bc 100644 --- a/admin/src/pages/PadPage.tsx +++ b/admin/src/pages/PadPage.tsx @@ -3,282 +3,419 @@ import {useEffect, useMemo, useState} from "react"; import {useStore} from "../store/store.ts"; import {PadSearchQuery, PadSearchResult} from "../utils/PadSearch.ts"; import {useDebounce} from "../utils/useDebounce.ts"; -import {determineSorting} from "../utils/sorting.ts"; import * as Dialog from "@radix-ui/react-dialog"; -import {IconButton} from "../components/IconButton.tsx"; -import {ChevronLeft, ChevronRight, Eye, Trash2, FileStack, PlusIcon} from "lucide-react"; -import {SearchField} from "../components/SearchField.tsx"; +import {ChevronLeft, ChevronRight, Eye, Trash2, FileStack, PlusIcon, Search, X, RefreshCw, History} from "lucide-react"; import {useForm} from "react-hook-form"; -type PadCreateProps = { - padName: string +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 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 { + 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` +} + +function fmtDate(ts: number): string { + const d = new Date(ts) + return ( + d.toLocaleDateString('de-DE', {day: '2-digit', month: 'short', year: 'numeric'}) + + ' · ' + + d.toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'}) + ) } -export const PadPage = ()=>{ - const settingsSocket = useStore(state=>state.settingsSocket) +export const PadPage = () => { + const settingsSocket = useStore(state => state.settingsSocket) const [searchParams, setSearchParams] = useState({ - offset: 0, - limit: 12, - pattern: '', - sortBy: 'padName', - ascending: true + offset: 0, limit: 12, pattern: '', sortBy: 'lastEdited', ascending: false, }) 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 [createPadDialogOpen, setCreatePadDialogOpen] = useState(false) + const [searchTerm, setSearchTerm] = useState('') + const [filter, setFilter] = useState('all') + 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(()=>{ - if(!pads){ - return 0; - } - return Math.ceil(pads!.total / searchParams.limit) - },[pads, searchParams.limit]) + const pages = useMemo( + () => pads ? Math.ceil(pads.total / searchParams.limit) : 0, + [pads, searchParams.limit] + ) - useDebounce(()=>{ - setSearchParams({ - ...searchParams, - pattern: searchTerm - }) + const filteredResults = useMemo(() => { + const r = pads?.results ?? [] + if (filter === 'active') return r.filter(p => p.userCount > 0) + if (filter === 'recent') return r.filter(p => isRecent(p.lastEdited)) + if (filter === 'empty') return r.filter(p => p.revisionNumber === 0) + if (filter === 'stale') return r.filter(p => isStale(p.lastEdited)) + return r + }, [pads, filter]) + + const totalUsers = useMemo(() => (pads?.results ?? []).reduce((s, p) => s + p.userCount, 0), [pads]) + const activeCount = useMemo(() => (pads?.results ?? []).filter(p => p.userCount > 0).length, [pads]) + const emptyCount = useMemo(() => (pads?.results ?? []).filter(p => p.revisionNumber === 0).length, [pads]) + const lastActivity = useMemo(() => { + const r = pads?.results ?? [] + return r.length ? Math.max(...r.map(p => p.lastEdited)) : null + }, [pads]) + + const allSelected = filteredResults.length > 0 && filteredResults.every(p => selected.has(p.padName)) + const toggleAll = () => { + const s = new Set(selected) + if (allSelected) filteredResults.forEach(p => s.delete(p.padName)) + else filteredResults.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(() => { + setSearchParams({...searchParams, pattern: searchTerm}) }, 500, [searchTerm]) useEffect(() => { - if(!settingsSocket){ - return - } - + if (!settingsSocket) return settingsSocket.emit('padLoad', searchParams) - - }, [settingsSocket, searchParams]); + }, [settingsSocket, searchParams]) useEffect(() => { - if(!settingsSocket){ - return - } + if (!settingsSocket) return - settingsSocket.on('results:padLoad', (data: PadSearchResult)=>{ - useStore.getState().setPads(data); + 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}) + }) - 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 - }) + 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) + } }) - type SettingsSocketCreateReponse = { - error: string - } | { - success: string - } + settingsSocket.on('results:cleanupPadRevisions', (data) => { + const newPads = useStore.getState().pads?.results ?? [] + if (data.error) { setErrorText(data.error); return } + newPads.forEach(p => { if (p.padName === data.padId) p.revisionNumber = data.keepRevisions }) + useStore.getState().setPads({results: newPads, total: useStore.getState().pads!.total}) + }) + }, [settingsSocket, pads]) - settingsSocket.on('results:createPad', (rep: SettingsSocketCreateReponse)=>{ - 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) - // reload pads - settingsSocket.emit('padLoad', searchParams) - } - }) + const deletePad = (id: string) => settingsSocket?.emit('deletePad', id) + const cleanupPad = (id: string) => settingsSocket?.emit('cleanupPadRevisions', id) + const onPadCreate = (data: PadCreateProps) => settingsSocket?.emit('createPad', {padName: data.padName}) - settingsSocket.on('results:cleanupPadRevisions', (data)=>{ - const newPads = useStore.getState().pads?.results ?? [] + return ( +
- if (data.error) { - setErrorText(data.error) - return - } + {/* ── Dialogs ── */} + + + + +
{t('ep_admin_pads:ep_adminpads2_confirm', {padID: padToDelete})}
+
+ + +
+
+
+
- newPads.forEach((pad)=>{ - if (pad.padName === data.padId) { - pad.revisionNumber = data.keepRevisions - } - }) + + + + +
Fehler: {errorText}
+
+ +
+
+
+
- useStore.getState().setPads({ - results: newPads, - total: useStore.getState().pads!.total - }) - }) - }, [settingsSocket, pads]); + + + + + +
+ +
+ + +
+ +
+
+
+
- const deletePad = (padID: string)=>{ - settingsSocket?.emit('deletePad', padID) - } + {/* ── Page header ── */} +
+
+
Admin Pads
+

+

Übersicht aller Pads dieser Etherpad-Instanz. Suchen, aufräumen, öffnen.

+
+
+ + +
+
- const cleanupPad = (padID: string)=>{ - settingsSocket?.emit('cleanupPadRevisions', padID) - } + {/* ── Stats ── */} +
+
+
Pads gesamt
+
{pads?.total ?? '—'}
+
{activeCount > 0 ? `${activeCount} gerade aktiv` : 'Keine aktiven Nutzer'}
+
+
+
Aktive Nutzer
+
{totalUsers}
+
über alle Pads hinweg
+
+
0 ? ' pm-stat--warn' : ''}`}> +
Leere Pads
+
{emptyCount}
+
0 Revisionen
+ {emptyCount > 0 && ( + + )} +
+
+
Letzte Aktivität
+
+ {lastActivity ? relativeTime(lastActivity) : '—'} +
+
{pads?.results?.[0]?.padName ?? ''}
+
+
- const onPadCreate = (data: PadCreateProps)=>{ - settingsSocket?.emit('createPad', { - padName: data.padName - }) - } + {/* ── Pads section ── */} +
+
+

Alle Pads

+ {filteredResults.length} +
+
+
+ + setSearchTerm(e.target.value)} + placeholder={t('ep_admin_pads:ep_adminpads2_search-heading')} + /> + {searchTerm && ( + + )} +
+ +
+
+ {/* Filter chips */} +
+ {PAD_FILTERS.map(f => ( + + ))} +
- return
- - - -
-
-
- {t("ep_admin_pads:ep_adminpads2_confirm", { - padID: padToDelete, - })} + {/* Bulk bar */} + {selected.size > 0 && ( +
+ {selected.size} ausgewählt +
+ + +
-
- - + )} + + {filteredResults.length > 0 ? ( +
+ + + + + + + + + + + + + {filteredResults.map(pad => { + const isEmpty = pad.revisionNumber === 0 + const isSel = selected.has(pad.padName) + return ( + + + + + + + + + ) + })} + +
+ + PadNutzerRevisionenZuletzt bearbeitetAktion
+ + +
+ + + +
+
{pad.padName}
+
+ {isEmpty ? 'leer · noch nie bearbeitet' : `${pad.revisionNumber} Revisionen`} +
+
+
+
+ {pad.userCount > 0 ? ( + {pad.userCount} + ) : ( + 0 + )} + {pad.revisionNumber.toLocaleString('de-DE')} +
+ {relativeTime(pad.lastEdited)} + {fmtDate(pad.lastEdited)} +
+
+
+ + + +
+
+ ) : ( +
+
+
Keine Pads gefunden
+
+ )} + + {/* Pagination */} +
+ + {currentPage + 1} / {pages || 1} +
- - - - - - - -
-
Error occured: {errorText}
-
- -
-
-
-
-
- - - - - -
- -
- - -
- -
-
-
-
- -

- } title={} onClick={()=>{ - setCreatePadDialogOpen(true) - }}/> -
- 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={} onClick={()=>window.open(`../../p/${pad.padName}`, '_blank')}/> -
-
-
- - {currentPage+1} out of {pages} - +
-
+ ) } diff --git a/admin/src/pages/Plugin.ts b/admin/src/pages/Plugin.ts index 72c768c5307..e33f4ec495d 100644 --- a/admin/src/pages/Plugin.ts +++ b/admin/src/pages/Plugin.ts @@ -4,6 +4,7 @@ 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 @@ -17,7 +18,8 @@ export type InstalledPlugin = { name: string, path: string, realPath: string, - version:string, + version: string, + description?: string, updatable?: boolean } @@ -26,7 +28,7 @@ export type SearchParams = { searchTerm: string, offset: number, limit: number, - sortBy: 'name'|'version'|'last-updated', + sortBy: 'name'|'version'|'last-updated'|'downloads', sortDir: 'asc'|'desc' } From 4a47a6f79bd8a6483721a052d658f5f39e859e18 Mon Sep 17 00:00:00 2001 From: SamTV12345 <40429738+samtv12345@users.noreply.github.com> Date: Sun, 10 May 2026 17:55:57 +0200 Subject: [PATCH 2/2] chore: fixed qodoos remarks --- admin/src/pages/HomePage.tsx | 60 ++++++++++++------- .../admin-spec/admintroubleshooting.spec.ts | 21 +++---- .../admin-spec/adminupdateplugins.spec.ts | 39 ++++++------ 3 files changed, 66 insertions(+), 54 deletions(-) diff --git a/admin/src/pages/HomePage.tsx b/admin/src/pages/HomePage.tsx index ad4281e63fc..307769352c7 100644 --- a/admin/src/pages/HomePage.tsx +++ b/admin/src/pages/HomePage.tsx @@ -58,45 +58,63 @@ export const HomePage = () => { useEffect(() => { if (!pluginsSocket) return - pluginsSocket.on('results:installed', (data: {installed: InstalledPlugin[]}) => { + const onInstalled = (data: {installed: InstalledPlugin[]}) => { setInstalledPlugins(data.installed) - }) - - pluginsSocket.on('results:updatable', (data) => { + } + const onUpdatable = (data: {updatable: string[]}) => { const updated = useStore.getState().installedPlugins.map(plugin => data.updatable.includes(plugin.name) ? {...plugin, updatable: true} : plugin ) setInstalledPlugins(updated) - }) - - pluginsSocket.on('finished:install', () => { + } + const onFinishedInstall = () => { pluginsSocket.emit('getInstalled') - }) - - pluginsSocket.on('finished:uninstall', () => { + } + const onFinishedUninstall = () => { console.log('Finished uninstall') - }) - - pluginsSocket.on('connect', () => { + } + const onConnect = () => { pluginsSocket.emit('getInstalled') pluginsSocket.emit('search', searchParams) - }) + } + + 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.emit('getInstalled') const interval = setInterval(() => pluginsSocket.emit('checkUpdates'), 1000 * 60 * 5) - return () => clearInterval(interval) + 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]) useEffect(() => { if (!pluginsSocket) return - pluginsSocket.emit('search', searchParams) - pluginsSocket.on('results:search', (data: {results: PluginDef[]}) => { + + const onSearchResults = (data: {results: PluginDef[]}) => { setPlugins(data.results) - }) - pluginsSocket.on('results:searcherror', () => { + } + const onSearchError = () => { useStore.getState().setToastState({open: true, title: 'Error retrieving plugins', success: false}) - }) + } + + 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]) const uninstallPlugin = (pluginName: string) => { @@ -137,7 +155,7 @@ export const HomePage = () => { diff --git a/src/tests/frontend-new/admin-spec/admintroubleshooting.spec.ts b/src/tests/frontend-new/admin-spec/admintroubleshooting.spec.ts index 57518e3fe1d..e5d56bdd25e 100644 --- a/src/tests/frontend-new/admin-spec/admintroubleshooting.spec.ts +++ b/src/tests/frontend-new/admin-spec/admintroubleshooting.spec.ts @@ -15,30 +15,27 @@ test('Shows troubleshooting page manager', async ({page}) => { await page.waitForSelector('.menu') const menu = page.locator('.menu'); // Sidebar nav: plugins, settings, help, pads, shout, update. - await expect(menu.locator('li')).toHaveCount(6); + await expect(menu.locator('.sidebar-nav-item')).toHaveCount(6); }) test('Shows a version number', async function ({page}) { await page.goto('http://localhost:9001/admin/help') - await page.waitForSelector('.menu') - const helper = page.locator('.help-block').locator('div').nth(1) - const version = (await helper.textContent())!.split('.'); + await page.waitForSelector('.pm-hv-num') + const version = (await page.locator('.pm-hv-num').textContent())!.split('.'); expect(version.length).toBe(3) }); test('Lists installed parts', async function ({page}) { await page.goto('http://localhost:9001/admin/help') - await page.waitForSelector('.menu') - await page.waitForSelector('.innerwrapper ul') - const parts = page.locator('.innerwrapper ul').nth(1); + await page.waitForSelector('.pm-tag-cloud') + // First tag cloud = installed plugins, second = installed parts + const parts = page.locator('.pm-tag-cloud').nth(1); expect(await parts.textContent()).toContain('ep_etherpad-lite/adminsettings'); }); test('Lists installed hooks', async function ({page}) { await page.goto('http://localhost:9001/admin/help') - await page.waitForSelector('.menu') - await page.waitForSelector('.innerwrapper ul') - const helper = page.locator('.innerwrapper ul').nth(2); - expect(await helper.textContent()).toContain('express'); + await page.waitForSelector('.pm-hooks') + const hooks = page.locator('.pm-hooks'); + expect(await hooks.textContent()).toContain('express'); }); - diff --git a/src/tests/frontend-new/admin-spec/adminupdateplugins.spec.ts b/src/tests/frontend-new/admin-spec/adminupdateplugins.spec.ts index b426fc6f661..9056dc4f22d 100644 --- a/src/tests/frontend-new/admin-spec/adminupdateplugins.spec.ts +++ b/src/tests/frontend-new/admin-spec/adminupdateplugins.spec.ts @@ -15,30 +15,31 @@ test.beforeEach(async ({ page })=>{ test.describe('Plugins page', ()=> { test('List some plugins', async ({page}) => { - await page.waitForSelector('.search-field'); - const pluginTable = page.locator('table tbody').nth(1); + await page.waitForSelector('.pm-search-input'); + // Installed plugins are now a flex list; available plugins are in the sole + const pluginTable = page.locator('table tbody').first(); await expect(pluginTable).not.toBeEmpty() }) test('Searches for a plugin', async ({page}) => { - await page.waitForSelector('.search-field'); - await page.click('.search-field') + await page.waitForSelector('.pm-search-input'); + await page.click('.pm-search-input') await page.keyboard.type('ep_set_title_on_pad') - const pluginTable = page.locator('table tbody').nth(1); + const pluginTable = page.locator('table tbody').first(); await expect(pluginTable.locator('tr').first()).toContainText('ep_set_title_on_pad', {timeout: 60000}) }) test('Attempt to Install and Uninstall a plugin', async ({page}) => { - await page.waitForSelector('.search-field'); - const pluginTable = page.locator('table tbody').nth(1); + await page.waitForSelector('.pm-search-input'); + const pluginTable = page.locator('table tbody').first(); await expect(pluginTable).not.toBeEmpty({ timeout: 15000 }) // Now everything is loaded, lets install a plugin - await page.click('.search-field') + await page.click('.pm-search-input') await page.keyboard.type('ep_set_title_on_pad') await page.keyboard.press('Enter') @@ -46,23 +47,19 @@ test.describe('Plugins page', ()=> { const pluginRow = pluginTable.locator('tr').first() await expect(pluginRow).toContainText('ep_set_title_on_pad', {timeout: 60000}) - // Select Installation button - await pluginRow.locator('td').nth(4).locator('button').first().click() - await page.waitForSelector('table tbody') + // Install button is in the last table cell + await pluginRow.locator('td').last().locator('button').first().click() + await page.waitForSelector('.pm-installed') - // The installed-plugins table can grow by more than one row if the - // installed plugin pulls in transitive plugin deps (e.g. - // ep_set_title_on_pad depends on ep_plugin_helpers as of 0.7.2), so - // assert by name rather than by row count. - const installedPlugins = page.locator('table tbody').first() - const installedPluginRow = installedPlugins.locator('tr', {hasText: 'ep_set_title_on_pad'}) + // Installed plugins are now in .pm-installed-row flex items (not a table). + // Assert by name rather than by row count — transitive deps may also appear. + const installedPluginRow = page.locator('.pm-installed-row', {hasText: 'ep_set_title_on_pad'}) await expect(installedPluginRow).toHaveCount(1, {timeout: 15000}) - await installedPluginRow.locator('td').nth(2).locator('button').first().click() + // Uninstall button is inside .pm-installed-actions + await installedPluginRow.locator('.pm-installed-actions button').first().click() - // Wait for the uninstallation to complete: the row for - // ep_set_title_on_pad should disappear. Other installed plugins - // (etherpad-lite itself, transitive plugin deps) may stay. + // Wait for the uninstallation to complete: the row should disappear. await expect(installedPluginRow).toHaveCount(0, {timeout: 15000}) await page.waitForTimeout(5000) })