diff --git a/assets/css/admin.css b/assets/css/admin.css index 6f8eac9..2bf4ce4 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -260,16 +260,17 @@ padding-bottom: 10px; } + /* Room for "Standard (indirekt)" in the select on phones. */ .lit-mcp-tools-table .column-tool { - width: 45%; + width: 40%; } .lit-mcp-tools-table .column-kind { - width: 12%; + width: 10%; } .lit-mcp-tools-table .column-mode { - width: 43%; + width: 50%; } .lit-mcp-tools-table th, @@ -296,7 +297,36 @@ } } -/* Tab "Protokoll": a core-style list table; the change is the wide primary column. */ +/* 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 { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; + margin: 8px 0; +} + +.lit-mcp-log-filters select { + max-width: 14rem; +} + +.lit-mcp-date { + display: flex; + align-items: center; + gap: 4px; +} + +.lit-mcp-log-filters .lit-mcp-reset { + line-height: 2.30769231; +} + +/* "Protokoll leeren": a destructive action, kept apart from the data it removes. */ +.lit-mcp-clear-log { + margin-top: 1em; + text-align: right; +} + .lit-mcp .lit_mcp_log_entries .column-object { width: 24%; } @@ -349,24 +379,6 @@ box-shadow: inset 4px 0 0 #d63638; } -.lit-mcp .tablenav .actions select { - max-width: 14rem; -} - -.lit-mcp-date { - float: left; - display: flex; - align-items: center; - gap: 4px; - margin-right: 6px; -} - -.lit-mcp .tablenav .lit-mcp-reset { - display: inline-block; - line-height: 2.30769231; - margin-left: 8px; -} - /* Tab "Limits". */ .lit-mcp-unit { margin-left: 4px; diff --git a/assets/js/admin.js b/assets/js/admin.js index e950819..2eeb77e 100644 --- a/assets/js/admin.js +++ b/assets/js/admin.js @@ -1,37 +1,127 @@ -/* Einstellungen → LIT MCP: copy the server URL, collapsible tool groups, filter paging. */ +/* 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. */ ( function () { 'use strict'; - document.addEventListener( 'DOMContentLoaded', function () { + // "URL kopieren" – like WordPress' "Copy URL to clipboard" in the media library. + function setUpCopyButton() { var hideTimeout; - // "URL kopieren" – like WordPress' "Copy URL to clipboard" in the media library. - if ( window.ClipboardJS && document.querySelector( '.lit-mcp-copy' ) ) { - new window.ClipboardJS( '.lit-mcp-copy' ).on( 'success', function ( event ) { - var success = event.trigger.parentNode.querySelector( '.success' ); - - event.clearSelection(); - event.trigger.focus(); - if ( success ) { - clearTimeout( hideTimeout ); - success.classList.remove( 'hidden' ); - hideTimeout = setTimeout( function () { - success.classList.add( 'hidden' ); - }, 3000 ); - } - if ( window.wp && window.wp.a11y && window.litMcpAdmin ) { - window.wp.a11y.speak( window.litMcpAdmin.copied ); - } - } ); + if ( ! window.ClipboardJS || ! document.querySelector( '.lit-mcp-copy' ) ) { + return; } + new window.ClipboardJS( '.lit-mcp-copy' ).on( 'success', function ( event ) { + var success = event.trigger.parentNode.querySelector( '.success' ); - // Tool groups: WordPress' own postbox toggles, closed state saved per user. + event.clearSelection(); + event.trigger.focus(); + if ( success ) { + clearTimeout( hideTimeout ); + success.classList.remove( 'hidden' ); + hideTimeout = setTimeout( function () { + success.classList.add( 'hidden' ); + }, 3000 ); + } + if ( window.wp && window.wp.a11y && window.litMcpAdmin ) { + window.wp.a11y.speak( window.litMcpAdmin.copied ); + } + } ); + } + + // Tool groups: WordPress' own postbox toggles, closed state saved per user. + function setUpGroupToggles() { if ( window.postboxes && window.pagenow && document.querySelector( '.lit-mcp-groups' ) ) { window.postboxes.add_postbox_toggles( window.pagenow ); } + } - // Protokoll: a changed filter starts again on page 1. - document.querySelectorAll( '.lit-mcp .tablenav .actions select, .lit-mcp .tablenav .actions input' ).forEach( + /* + * Tools: a group's mode and its "nur lesen" switch show on the group's rows at once, + * the same way the page renders them after saving (the server stays the authority). + */ + function setUpLiveGroupFeedback() { + var groups = document.querySelector( '.lit-mcp-groups' ); + + if ( ! groups ) { + return; + } + groups.querySelectorAll( '.lit-mcp-group' ).forEach( function ( group ) { + var mode = group.querySelector( '.lit-mcp-group-mode' ); + var readonly = group.querySelector( '.lit-mcp-group-readonly' ); + var tools = group.querySelectorAll( '.lit-mcp-tool-mode' ); + + if ( mode ) { + mode.addEventListener( 'change', function () { + tools.forEach( function ( select ) { + labelInheritedOption( select, mode, groups.dataset.likeGroup ); + } ); + } ); + } + if ( readonly ) { + readonly.addEventListener( 'change', function () { + tools.forEach( function ( select ) { + if ( select.dataset.writes ) { + lockTool( select, readonly.checked ); + } + } ); + } ); + } + } ); + } + + // First option: "Standard (…)" without a group mode, "wie Gruppe (…)" with one. + function labelInheritedOption( select, mode, likeGroup ) { + var label; + + if ( select.dataset.required ) { + return; + } + if ( ! mode.value ) { + select.options[ 0 ].text = select.dataset.defaultLabel; + return; + } + label = mode.options[ mode.selectedIndex ].text; + if ( select.dataset.directOnly && mode.value === 'indirect' ) { + label = optionText( select, 'direct' ) || label; + } + select.options[ 0 ].text = likeGroup.replace( '%s', label ); + } + + function optionText( select, value ) { + var option = select.querySelector( 'option[value="' + value + '"]' ); + + return option ? option.text : ''; + } + + /* + * "Nur lesen": the select of a writing tool is disabled and its note shown. A disabled + * select is not submitted, so its own setting is kept in a hidden field meanwhile + * (the server does the same when it renders a locked row). + */ + function lockTool( select, locked ) { + var cell = select.parentNode; + var note = cell.querySelector( '.lit-mcp-readonly-note' ); + var keep = cell.querySelector( 'input[type="hidden"][name="' + select.name + '"]' ); + + select.disabled = locked; + if ( locked && select.value && ! keep ) { + keep = document.createElement( 'input' ); + keep.type = 'hidden'; + keep.name = select.name; + keep.value = select.value; + cell.appendChild( keep ); + } + if ( ! locked && keep ) { + keep.remove(); + } + if ( note ) { + note.hidden = ! locked; + } + } + + // Protokoll: a changed filter starts again on page 1. + function setUpFilterPaging() { + document.querySelectorAll( '.lit-mcp-log-filters select, .lit-mcp-log-filters input' ).forEach( function ( control ) { control.addEventListener( 'change', function () { var paged = control.form && control.form.querySelector( 'input[name="paged"]' ); @@ -41,5 +131,41 @@ } ); } ); + } + + // Zugriff: ask before saving a role set that leaves nobody, or the admin themselves, with access. + function setUpAccessConfirmation() { + var form = document.querySelector( '.lit-mcp-access-form' ); + + if ( ! form ) { + return; + } + form.addEventListener( 'submit', function ( event ) { + var checked = Array.prototype.map.call( + form.querySelectorAll( 'input[type="checkbox"]:checked' ), + function ( box ) { + return box.value; + } + ); + var own = form.dataset.ownRoles ? form.dataset.ownRoles.split( ' ' ) : null; + var question = ''; + + if ( checked.length === 0 ) { + question = form.dataset.confirmNone; + } else if ( own && ! own.some( function ( role ) { return checked.indexOf( role ) !== -1; } ) ) { + question = form.dataset.confirmOwn; + } + if ( question && ! window.confirm( question ) ) { + event.preventDefault(); + } + } ); + } + + document.addEventListener( 'DOMContentLoaded', function () { + setUpCopyButton(); + setUpGroupToggles(); + setUpLiveGroupFeedback(); + setUpFilterPaging(); + setUpAccessConfirmation(); } ); }() ); diff --git a/src/Admin/AccessTab.php b/src/Admin/AccessTab.php index 726e582..ead5a0b 100644 --- a/src/Admin/AccessTab.php +++ b/src/Admin/AccessTab.php @@ -68,29 +68,33 @@ final class AccessTab implements AdminTab ))); if ($roles === []) { - self::warnOnce( - 'lit_mcp_no_roles', - __('Keine Rolle freigegeben – niemand kann LIT MCP nutzen.', 'wp-lit-mcp') - ); + self::warnOnce('lit_mcp_no_roles', self::noRolesWarning()); } elseif (self::currentUserLosesAccess($roles)) { - self::warnOnce( - 'lit_mcp_own_role', - __( - 'Keine Ihrer Rollen ist mehr freigegeben – Sie selbst können LIT MCP nicht mehr nutzen.', - 'wp-lit-mcp' - ) - ); + self::warnOnce('lit_mcp_own_role', self::ownRoleWarning()); } return $roles; } + private static function noRolesWarning(): string + { + return __('Keine Rolle freigegeben – niemand kann LIT MCP nutzen.', 'wp-lit-mcp'); + } + + private static function ownRoleWarning(): string + { + return __( + 'Keine Ihrer Rollen ist mehr freigegeben – Sie selbst können LIT MCP nicht mehr nutzen.', + 'wp-lit-mcp' + ); + } + public function render(): void { $allowed = $this->access->allowedRoles(); $legend = __('Rollen mit Zugriff', 'wp-lit-mcp'); - echo '