diff --git a/assets/css/admin.css b/assets/css/admin.css index 2bf4ce4..a5447bb 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -9,6 +9,11 @@ gap: 8px 16px; } +.lit-mcp-logo-link { + display: inline-block; + padding: 4px 0; +} + .lit-mcp-logo { display: block; height: 30px; @@ -25,6 +30,33 @@ margin-bottom: 1em; } +/* + * Phones: the tabs form an even grid instead of ragged wrapped lines, each at least 44 px high + * (core's tab is about 34 px). The active tab keeps core's look, marked by its colour and underline. + */ +@media screen and (max-width: 782px) { + .lit-mcp .nav-tab-wrapper { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(7.5rem, 1fr)); + gap: 4px; + padding-bottom: 0; + border-bottom: 0; + } + + .lit-mcp .nav-tab-wrapper .nav-tab { + float: none; + margin: 0; + padding: 12px 8px; + text-align: center; + border: 1px solid #c3c4c7; + } + + .lit-mcp .nav-tab-wrapper .nav-tab-active { + border-color: #2271b1; + box-shadow: inset 0 -2px 0 #2271b1; + } +} + /* Muted secondary text (tool names, client URLs). */ .lit-mcp-muted, .lit-mcp-tool-name { @@ -46,6 +78,19 @@ margin-bottom: 0.75em; } +/* Label and field share a line; the field takes the rest and wraps below on narrow screens. */ +.lit-mcp-url-line { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; +} + +.lit-mcp-url-line .lit-mcp-copy-row { + flex: 1 1 22rem; + margin-top: 0; +} + .lit-mcp-copy-row { display: flex; flex-wrap: wrap; @@ -83,21 +128,59 @@ color: #50575e; } -.lit-mcp-status-yes-alt .dashicons, -.lit-mcp-result-yes-alt .dashicons { - color: #00a32a; +.lit-mcp-state .dashicons { + vertical-align: text-bottom; } -.lit-mcp-status-warning .dashicons, -.lit-mcp-result-warning .dashicons { - color: #dba617; +/* Divi tab: the report is a plain list, the icon replaces the bullet. */ +.lit-mcp-divi-report { + list-style: none; + margin-left: 0; } -.lit-mcp-status-dismiss .dashicons, -.lit-mcp-result-dismiss .dashicons { +.lit-mcp-divi-report ul { + list-style: disc; + margin-left: 2em; +} + +.lit-mcp-status-yes-alt .dashicons { + color: #008a20; +} + +.lit-mcp-status-warning .dashicons { + color: #996800; +} + +.lit-mcp-status-dismiss .dashicons { color: #d63638; } +/* Folded help ("Wo finde ich das?") and the Divi detail table. */ +.lit-mcp-hint, +.lit-mcp-divi-details { + margin-top: 6px; +} + +.lit-mcp-hint summary, +.lit-mcp-divi-details summary { + cursor: pointer; +} + +.lit-mcp-hint p { + max-width: 40rem; +} + +.lit-mcp-divi-details summary { + font-size: 14px; + font-weight: 600; + margin: 1em 0 0.5em; +} + +/* Only on phones: text that replaces a hidden column. */ +.lit-mcp-phone-only { + display: none; +} + /* Tab "Zugriff". */ .lit-mcp-connections + .description { margin-top: 8px; @@ -113,6 +196,10 @@ display: none; } + .lit-mcp-phone-only { + display: inline; + } + .lit-mcp-connections .column-actions { width: auto; } @@ -122,7 +209,48 @@ } } -/* Tab "Tools": legend, collapsible groups (WordPress postboxes) with aligned columns. */ +/* Tab "Tools": search and open/close above the groups, legend, collapsible groups (WordPress postboxes). */ +.lit-mcp-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 12px; + margin: 0 0 12px; +} + +.lit-mcp-tool-search { + min-width: 16rem; +} + +.lit-mcp-filter-status { + color: #50575e; +} + +.lit-mcp-groups [hidden] { + display: none !important; +} + +.lit-mcp-group-reset { + font-size: 13px; +} + +/* Phones: touch targets of at least 24 px (WCAG 2.5.8) for the reset link and the folded help. */ +@media screen and (max-width: 782px) { + .lit-mcp-group-reset { + min-height: 32px; + } + + .lit-mcp-hint summary, + .lit-mcp-divi-details summary { + padding: 6px 0; + } +} + +/* Explains the icons that replace the "Art" words on phones. */ +.lit-mcp-kind-legend { + display: none; +} + .lit-mcp-legend { display: grid; grid-template-columns: max-content 1fr; @@ -144,7 +272,13 @@ padding-left: 12px; } +/* + * The title keeps room of its own (12rem): when title, arrow and controls do not fit one line, the controls + * drop to the next line instead of squeezing the title into a narrow column or sending the arrow alone below it. + */ .lit-mcp-groups .postbox .hndle { + flex: 1 1 12rem; + min-width: 0; cursor: pointer; justify-content: flex-start; gap: 6px; @@ -254,12 +388,16 @@ } @media screen and (max-width: 782px) { - .lit-mcp-group-controls { - order: 3; + /* No "order": title, toggle, controls read the same for the eye and for Tab. */ + .lit-mcp-groups .lit-mcp-group-controls { flex-basis: 100%; padding-bottom: 10px; } + .lit-mcp-kind-legend { + display: block; + } + /* Room for "Standard (indirekt)" in the select on phones. */ .lit-mcp-tools-table .column-tool { width: 40%; @@ -297,6 +435,45 @@ } } +/* Divi tab: on phones each row of the feature table becomes a card named by data-label. */ +@media screen and (max-width: 782px) { + .lit-mcp-divi-features thead { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + } + + .lit-mcp-divi-features tr, + .lit-mcp-divi-features td { + display: block; + } + + /* One card per row: a rule and room between them, the feature name first and bold. */ + .lit-mcp-divi-features tr { + padding: 8px 0; + border-bottom: 1px solid #c3c4c7; + } + + .lit-mcp-divi-features td:first-child { + font-weight: 600; + } + + .lit-mcp-divi-features code { + overflow-wrap: anywhere; + } + + .lit-mcp-divi-features td::before { + content: attr(data-label) ": "; + font-weight: 600; + } + + .lit-mcp-divi-features ul { + margin: 0; + } +} + /* Tab "Protokoll": filters in their own block above the table (core hides ".tablenav .actions" on phones); a core-style list table whose change is the wide primary column. */ .lit-mcp-log-filters { @@ -311,6 +488,13 @@ max-width: 14rem; } +.lit-mcp-dates { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; +} + .lit-mcp-date { display: flex; align-items: center; diff --git a/assets/js/admin.js b/assets/js/admin.js index 2eeb77e..0c8fd4a 100644 --- a/assets/js/admin.js +++ b/assets/js/admin.js @@ -1,5 +1,5 @@ -/* Einstellungen → LIT MCP: copy the server URL, live group feedback and collapsible tool - groups, filter paging, and a question before a role save that locks people out. */ +/* Einstellungen → LIT MCP: copy the server URL, live group feedback, search and reset for the + collapsible tool groups, filter paging, and questions before a destructive or locking-out save. */ ( function () { 'use strict'; @@ -119,6 +119,206 @@ } } + function setGroupOpen( group, open ) { + var toggle = group.querySelector( '.handlediv' ); + + group.classList.toggle( 'closed', ! open ); + if ( toggle ) { + toggle.setAttribute( 'aria-expanded', open ? 'true' : 'false' ); + } + } + + function saveGroupState() { + if ( window.postboxes && window.pagenow ) { + window.postboxes.save_state( window.pagenow ); + } + } + + function markAllUserSet( groups ) { + groups.forEach( function ( group ) { + group.dataset.litUserSet = '1'; + } ); + } + + // Open or close every group; with `save`, remember it like a click on a single toggle would. + function setAllGroups( groups, open, save ) { + groups.forEach( function ( group ) { + setGroupOpen( group, open ); + } ); + if ( save ) { + saveGroupState(); + } + } + + // Tools: search by name, "only changed", open/close all. Rows and empty groups hide instead of vanish. + function setUpToolFilter() { + var groups = document.querySelector( '.lit-mcp-groups' ); + var search = document.querySelector( '.lit-mcp-tool-search' ); + var only = document.querySelector( '.lit-mcp-only-changed' ); + var status = document.querySelector( '.lit-mcp-filter-status' ); + var collapse = document.querySelector( '.lit-mcp-collapse-all' ); + var expand = document.querySelector( '.lit-mcp-expand-all' ); + var closedBefore = null; + var all; + + if ( ! groups || ! search || ! only || ! collapse || ! expand ) { + return; + } + all = groups.querySelectorAll( '.lit-mcp-group' ); + + function countText( shown ) { + var text = window.litMcpAdmin; + + if ( ! text ) { + return ''; + } + return shown === 1 ? text.toolShown : text.toolsShown.replace( '%d', shown ); + } + + // The filter is cleared: forget the temporary state and, if the user changed groups meanwhile, save it now. + function finishFilter() { + var changed = false; + + closedBefore = null; + all.forEach( function ( group ) { + changed = changed || !! group.dataset.litUserSet; + delete group.dataset.litUserSet; + } ); + if ( changed ) { + saveGroupState(); + } + } + + function isFiltering() { + return search.value.trim() !== '' || only.checked; + } + + function apply() { + var term = search.value.trim().toLowerCase(); + var filtering = isFiltering(); + var shown = 0; + + /* + * A filter opens the groups with hits; clearing it puts every group back as the user had it. + * A group the user opens or closes themselves while filtering keeps that choice. + */ + if ( filtering && closedBefore === null ) { + closedBefore = Array.prototype.map.call( all, function ( group ) { + return group.classList.contains( 'closed' ); + } ); + } + all.forEach( function ( group, index ) { + var visible = 0; + + group.querySelectorAll( 'tbody tr' ).forEach( function ( row ) { + var select = row.querySelector( '.lit-mcp-tool-mode' ); + var cell = row.querySelector( '.column-tool' ); + var name = cell ? cell.textContent.toLowerCase() : ''; + var match = ( term === '' || name.indexOf( term ) !== -1 ) && + ( ! only.checked || ( select && select.value !== '' ) ); + + row.hidden = ! match; + visible += match ? 1 : 0; + } ); + group.hidden = visible === 0; + if ( filtering && visible > 0 && ! group.dataset.litUserSet ) { + setGroupOpen( group, true ); + } else if ( ! filtering && closedBefore && ! group.dataset.litUserSet ) { + setGroupOpen( group, ! closedBefore[ index ] ); + } + shown += visible; + } ); + if ( ! filtering && closedBefore ) { + finishFilter(); + } + if ( status ) { + status.textContent = filtering ? countText( shown ) : ''; + } + } + + // Enter in the search field would submit the whole settings form, half-done changes included. + search.addEventListener( 'keydown', function ( event ) { + if ( event.key === 'Enter' ) { + event.preventDefault(); + } + } ); + search.addEventListener( 'input', apply ); + only.addEventListener( 'change', apply ); + // While filtering, nothing is saved: the hidden groups would be stored as hidden boxes. + // "User set" only means something while a filter is on; outside of it a stale flag would stop the next filter. + collapse.addEventListener( 'click', function () { + if ( isFiltering() ) { + markAllUserSet( all ); + } + setAllGroups( all, false, ! isFiltering() ); + } ); + expand.addEventListener( 'click', function () { + if ( isFiltering() ) { + markAllUserSet( all ); + } + setAllGroups( all, true, ! isFiltering() ); + } ); + groups.addEventListener( 'click', function ( event ) { + var group = event.target.closest( '.handlediv, .hndle' ) && event.target.closest( '.lit-mcp-group' ); + + if ( group && isFiltering() ) { + group.dataset.litUserSet = '1'; + } + } ); + // The browser may restore the search text after a reload; make the list match it. + apply(); + } + + // Tools: "Auf Standard zurücksetzen" clears a group's mode, its "nur lesen" switch and every tool's own setting. + function setUpGroupReset() { + document.querySelectorAll( '.lit-mcp-group' ).forEach( function ( group ) { + var button = group.querySelector( '.lit-mcp-group-reset' ); + + if ( ! button ) { + return; + } + button.addEventListener( 'click', function () { + var readonly = group.querySelector( '.lit-mcp-group-readonly' ); + var mode = group.querySelector( '.lit-mcp-group-mode' ); + + // Order matters: unlock the rows first, then fall back to the default. + change( readonly, function () { + readonly.checked = false; + } ); + change( mode, function () { + mode.value = ''; + } ); + group.querySelectorAll( '.lit-mcp-tool-mode' ).forEach( function ( select ) { + change( select, function () { + select.value = ''; + } ); + } ); + if ( window.wp && window.wp.a11y && window.litMcpAdmin ) { + window.wp.a11y.speak( window.litMcpAdmin.groupReset ); + } + } ); + } ); + } + + function change( field, apply ) { + if ( ! field ) { + return; + } + apply(); + field.dispatchEvent( new Event( 'change', { bubbles: true } ) ); + } + + // A button with data-lit-mcp-confirm asks first (revoke a connection, clear the log). + function setUpConfirmButtons() { + document.addEventListener( 'click', function ( event ) { + var button = event.target.closest( '[data-lit-mcp-confirm]' ); + + if ( button && ! window.confirm( button.dataset.litMcpConfirm ) ) { + event.preventDefault(); + } + } ); + } + // Protokoll: a changed filter starts again on page 1. function setUpFilterPaging() { document.querySelectorAll( '.lit-mcp-log-filters select, .lit-mcp-log-filters input' ).forEach( @@ -162,9 +362,13 @@ } document.addEventListener( 'DOMContentLoaded', function () { + // First: the confirmations guard destructive buttons and must not depend on the rest starting up. + setUpConfirmButtons(); setUpCopyButton(); setUpGroupToggles(); setUpLiveGroupFeedback(); + setUpToolFilter(); + setUpGroupReset(); setUpFilterPaging(); setUpAccessConfirmation(); } ); diff --git a/src/Admin/AccessTab.php b/src/Admin/AccessTab.php index ead5a0b..72a399b 100644 --- a/src/Admin/AccessTab.php +++ b/src/Admin/AccessTab.php @@ -84,7 +84,7 @@ final class AccessTab implements AdminTab private static function ownRoleWarning(): string { return __( - 'Keine Ihrer Rollen ist mehr freigegeben – Sie selbst können LIT MCP nicht mehr nutzen.', + 'Keine deiner Rollen ist mehr freigegeben. Du kannst LIT MCP dann selbst nicht mehr nutzen.', 'wp-lit-mcp' ); } @@ -105,11 +105,14 @@ final class AccessTab implements AdminTab ); foreach (wp_roles()->get_names() as $role => $name) { printf( - '
', + '' + . ' (%5$s)
', esc_attr(Access::OPTION), esc_attr((string) $role), checked(in_array($role, $allowed, true), true, false), - esc_html(translate_user_role((string) $name)) + esc_html(translate_user_role((string) $name)), + esc_html(self::roleHint((string) $role)) ); } echo '

' @@ -122,6 +125,19 @@ final class AccessTab implements AdminTab $this->renderConnections(); } + /** What a role may do in WordPress, in one phrase, so the admin sees whom they are letting in. */ + private static function roleHint(string $role): string + { + $caps = (array) (((array) wp_roles()->roles)[$role]['capabilities'] ?? []); + + return match (true) { + ! empty($caps['manage_options']) => __('verwaltet die Website', 'wp-lit-mcp'), + ! empty($caps['edit_others_posts']) => __('bearbeitet auch fremde Inhalte', 'wp-lit-mcp'), + ! empty($caps['edit_posts']) || ! empty($caps['edit_pages']) => __('bearbeitet Inhalte', 'wp-lit-mcp'), + default => __('keine Bearbeitungsrechte erkannt', 'wp-lit-mcp'), + }; + } + public function handleRevoke(): void { $this->action->guard(); @@ -240,17 +256,22 @@ final class AccessTab implements AdminTab echo ''; echo '' . esc_html($connection['user_login']) . ''; + $created = (string) wp_date($dateFormat . ' ' . $timeFormat, $connection['created']); + // The "Verbunden am" column is hidden on phones; the date then shows under the client instead. printf( - '%s
%s', + '%s
%s' + . '
%s', esc_html($connection['name']), - esc_html($connection['app_id']) - ); - printf( - '%s', - esc_html((string) wp_date($dateFormat . ' ' . $timeFormat, $connection['created'])) + esc_html($connection['app_id']), + esc_html(sprintf( + /* translators: %s: date and time */ + __('Verbunden am %s', 'wp-lit-mcp'), + $created + )) ); + printf('%s', esc_html($created)); $lastUsed = $connection['last_used'] === null - ? '–' + ? __('Noch nie', 'wp-lit-mcp') : (string) wp_date($dateFormat, $connection['last_used']); echo '' . esc_html($lastUsed) . ''; echo ''; @@ -259,17 +280,31 @@ final class AccessTab implements AdminTab printf('', esc_attr((string) $connection['user_id'])); printf('', esc_attr($connection['uuid'])); wp_nonce_field(self::REVOKE_ACTION . '_' . $connection['uuid']); - $confirm = esc_js(sprintf( - /* translators: %s: client name */ - __('Verbindung „%s“ widerrufen? Der Assistent muss sich danach neu anmelden.', 'wp-lit-mcp'), - $connection['name'] - )); submit_button( __('Widerrufen', 'wp-lit-mcp'), 'delete small', 'submit', false, - ['onclick' => "return window.confirm('" . $confirm . "');"] + [ + // One id per row: submit_button() would give every button the id "submit". + 'id' => 'lit-mcp-revoke-' . $connection['uuid'], + // Starts with the visible word (WCAG 2.5.3) and says whose connection it is. + 'aria-label' => sprintf( + /* translators: 1: client name, 2: user login */ + __('Widerrufen: %1$s, %2$s', 'wp-lit-mcp'), + $connection['name'], + $connection['user_login'] + ), + 'data-lit-mcp-confirm' => sprintf( + /* translators: 1: client name, 2: user login */ + __( + 'Verbindung „%1$s“ von %2$s widerrufen? Der Assistent muss sich danach neu anmelden.', + 'wp-lit-mcp' + ), + $connection['name'], + $connection['user_login'] + ), + ] ); echo ''; } diff --git a/src/Admin/ConnectionTab.php b/src/Admin/ConnectionTab.php index 3efe473..ff56b6b 100644 --- a/src/Admin/ConnectionTab.php +++ b/src/Admin/ConnectionTab.php @@ -34,15 +34,19 @@ final class ConnectionTab implements AdminTab public function render(): void { + $checks = $this->status->checks(true); + + $this->renderProblems($checks); echo '

' . esc_html__('So verbindest du einen KI-Assistenten', 'wp-lit-mcp') . '

'; echo '
  1. '; printf( - '' + '' . '' . '' - . '' - . '', - esc_html__('Server-URL kopieren:', 'wp-lit-mcp'), + . '' + . '', + esc_html__('Server-URL', 'wp-lit-mcp'), esc_attr(ConnectionStatus::serverUrl()), esc_html__('URL kopieren', 'wp-lit-mcp'), esc_html__('Kopiert!', 'wp-lit-mcp') @@ -50,7 +54,17 @@ final class ConnectionTab implements AdminTab echo '
  2. ' . esc_html__( 'Im KI-Assistenten einen eigenen MCP-Server (Connector) hinzufügen und die URL einfügen.', 'wp-lit-mcp' - ) . '
  3. '; + ); + printf( + '
    %s

    %s

    ', + esc_html__('Wo finde ich das?', 'wp-lit-mcp'), + esc_html__( + // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument. + 'Je nach Assistent heißt der Punkt „Konnektoren“, „Connectors“ oder „MCP-Server“ und steht meist in den Einstellungen. Als Name passt der Name dieser Website; ein Passwort oder einen Schlüssel brauchst du nicht, die Anmeldung läuft über WordPress.', + 'wp-lit-mcp' + ) + ); + echo '
  4. '; printf( /* translators: %s: link to the "Zugriff" tab */ esc_html__('Anmelden und den Zugriff bestätigen. Danach erscheint die Verbindung unter %s.', 'wp-lit-mcp'), @@ -59,7 +73,7 @@ final class ConnectionTab implements AdminTab echo '
'; echo '

' . esc_html__('Status', 'wp-lit-mcp') . '