feat: admin settings page UI/UX pass with WordPress-native patterns
- Header: h1 + logo in a wrapping flex row, hr.wp-header-end before the tabs, aria-current on the active tab; own CSS/JS enqueued only on this screen. - Verbindung: ordered "So verbindest du Claude" steps, copy button (core clipboard.js + wp.a11y.speak), Site-Health-style status list, German details. - Zugriff: roles as form-table fieldset, connections with site date format, empty-state row, confirm on revoke; ConnectionUsage records last use via WP_Application_Passwords::record_application_password_usage (once a day). - Tools: plain-language legend, counter as inline notice, groups as core postboxes (postbox.js toggles, per-user closed state, summary when closed), fixed column widths with new "Art" column, submit above and below. - Protokoll: site-timezone times with UTC tooltip, result icons, filters with labels in the list table nav, retention link to Limits. - Limits: short labels with units and descriptions; snapshot count hidden but preserved. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+198
-69
@@ -61,79 +61,177 @@ final class ToolsTab implements AdminTab
|
||||
|
||||
public function render(): void
|
||||
{
|
||||
echo '<p>'
|
||||
. esc_html__('Direkt: sofort als eigenes Tool sichtbar.', 'wp-lit-mcp') . ' '
|
||||
. esc_html__('Indirekt: nur über die Meta-Tools (discover/execute) erreichbar.', 'wp-lit-mcp') . ' '
|
||||
. esc_html__('Deaktiviert: gar nicht verfügbar.', 'wp-lit-mcp')
|
||||
. '</p>';
|
||||
echo '<p>'
|
||||
. esc_html__(
|
||||
'Die Server-Anweisungen beim Verbinden nennen immer die aktuell direkt sichtbaren Tools.',
|
||||
'wp-lit-mcp'
|
||||
) . ' '
|
||||
. esc_html__(
|
||||
'Änderungen gelten also ab der nächsten Verbindung; in claude.ai ggf. den Connector neu verbinden.',
|
||||
'wp-lit-mcp'
|
||||
)
|
||||
. '</p>';
|
||||
echo '<p>' . esc_html__(
|
||||
'Das Divi-Code-Modul ist immer erlaubt (nur nachrangig) und hat keine eigene Einstellung.',
|
||||
'wp-lit-mcp'
|
||||
) . '</p>';
|
||||
$policy = $this->policy->withGroups($this->registry->groupMap());
|
||||
$groups = $this->registry->grouped();
|
||||
|
||||
$this->renderIntro(isset($groups['divi']));
|
||||
$this->renderCounter(count($policy->directNames($this->registry->abilities())));
|
||||
|
||||
// Lets WordPress' postbox script remember which groups each user closed.
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- core nonce markup.
|
||||
echo wp_nonce_field('closedpostboxes', 'closedpostboxesnonce', false, false);
|
||||
|
||||
echo '<form method="post" action="options.php">';
|
||||
settings_fields(self::GROUP);
|
||||
foreach ($this->registry->grouped() as $group => $abilities) {
|
||||
$this->renderGroup($policy, $group, $abilities);
|
||||
submit_button(null, 'primary', 'submit-top');
|
||||
echo '<div class="lit-mcp-groups">';
|
||||
$closed = $this->savedClosedBoxes();
|
||||
foreach ($groups as $group => $abilities) {
|
||||
$this->renderGroup($policy, $group, $abilities, $closed);
|
||||
}
|
||||
echo '</div>';
|
||||
submit_button();
|
||||
echo '</form>';
|
||||
}
|
||||
|
||||
private function renderCounter(int $direct): void
|
||||
private function renderIntro(bool $diviAvailable): void
|
||||
{
|
||||
echo '<p><strong>' . esc_html(sprintf(
|
||||
/* translators: %d: number of direct tools */
|
||||
_n('Direkt sichtbar: %d Tool', 'Direkt sichtbar: %d Tools', $direct, 'wp-lit-mcp'),
|
||||
$direct
|
||||
)) . '</strong></p>';
|
||||
echo '<p class="description">'
|
||||
. esc_html__('Lege fest, welche Tools Claude nutzen kann.', 'wp-lit-mcp') . ' '
|
||||
. esc_html__('Änderungen gelten ab der nächsten Verbindung.', 'wp-lit-mcp') . ' '
|
||||
. esc_html__('In claude.ai den Connector dafür gegebenenfalls neu verbinden.', 'wp-lit-mcp')
|
||||
. '</p>';
|
||||
|
||||
if ($direct > self::DIRECT_WARNING_THRESHOLD) {
|
||||
echo '<p class="description"><strong>' . esc_html__('Achtung:', 'wp-lit-mcp') . '</strong> '
|
||||
. esc_html__(
|
||||
'Viele direkte Tools kosten in claude.ai in jeder Unterhaltung Kontext.',
|
||||
'wp-lit-mcp'
|
||||
) . ' '
|
||||
. esc_html__('Selten gebrauchte Tools besser auf „indirekt“ stellen.', 'wp-lit-mcp')
|
||||
. '</p>';
|
||||
echo '<dl class="lit-mcp-legend">';
|
||||
$legend = [
|
||||
__('Direkt', 'wp-lit-mcp') => __('Claude sieht das Tool sofort.', 'wp-lit-mcp'),
|
||||
__('Indirekt', 'wp-lit-mcp') => __('Claude findet es bei Bedarf über die Tool-Suche.', 'wp-lit-mcp'),
|
||||
__('Aus', 'wp-lit-mcp') => __('Nicht verfügbar.', 'wp-lit-mcp'),
|
||||
__('Nur lesen', 'wp-lit-mcp') => __('Schaltet alle schreibenden Tools einer Gruppe aus.', 'wp-lit-mcp'),
|
||||
];
|
||||
foreach ($legend as $term => $text) {
|
||||
printf('<dt>%s</dt><dd>%s</dd>', esc_html($term), esc_html($text));
|
||||
}
|
||||
echo '</dl>';
|
||||
|
||||
if ($diviAvailable) {
|
||||
echo '<p class="description">' . esc_html__(
|
||||
'Das Divi-Code-Modul ist immer erlaubt (nur nachrangig) und hat keine eigene Einstellung.',
|
||||
'wp-lit-mcp'
|
||||
) . '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<Ability> $abilities */
|
||||
private function renderGroup(ExposurePolicy $policy, string $group, array $abilities): void
|
||||
private function renderCounter(int $direct): void
|
||||
{
|
||||
$count = count($abilities);
|
||||
$mixed = false;
|
||||
foreach ($abilities as $ability) {
|
||||
$mixed = $mixed || $policy->hasToolOverride($ability->name());
|
||||
}
|
||||
|
||||
$warn = $direct > self::DIRECT_WARNING_THRESHOLD;
|
||||
$counter = sprintf(
|
||||
/* translators: %d: number of direct tools */
|
||||
_n('Direkt sichtbar: %d Tool', 'Direkt sichtbar: %d Tools', $direct, 'wp-lit-mcp'),
|
||||
$direct
|
||||
);
|
||||
printf(
|
||||
'<details open class="lit-mcp-tool-group"><summary>%s</summary>',
|
||||
esc_html(sprintf(
|
||||
/* translators: 1: group label, 2: number of tools */
|
||||
_n('%1$s (%2$d Tool)', '%1$s (%2$d Tools)', $count, 'wp-lit-mcp'),
|
||||
$this->groupLabel($group),
|
||||
$count
|
||||
))
|
||||
'<div class="notice notice-%s inline"><p><strong>%s</strong>',
|
||||
$warn ? 'warning' : 'info',
|
||||
esc_html($counter)
|
||||
);
|
||||
|
||||
$groupMode = $policy->groupMode($group);
|
||||
if ($warn) {
|
||||
echo ' – '
|
||||
. esc_html__('Viele direkte Tools kosten in claude.ai in jeder Unterhaltung Kontext.', 'wp-lit-mcp')
|
||||
. ' '
|
||||
. esc_html__('Selten gebrauchte Tools besser auf „indirekt“ stellen.', 'wp-lit-mcp');
|
||||
}
|
||||
echo '</p></div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Closed postbox ids the user saved via WordPress' postbox script, or null if never saved.
|
||||
*
|
||||
* @return list<string>|null
|
||||
*/
|
||||
private function savedClosedBoxes(): ?array
|
||||
{
|
||||
$saved = get_user_option('closedpostboxes_' . SettingsPage::SCREEN_HOOK);
|
||||
|
||||
return is_array($saved) ? array_values(array_filter($saved, 'is_string')) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Ability> $abilities
|
||||
* @param list<string>|null $closedBoxes
|
||||
*/
|
||||
private function renderGroup(ExposurePolicy $policy, string $group, array $abilities, ?array $closedBoxes): void
|
||||
{
|
||||
$count = count($abilities);
|
||||
$label = $this->groupLabel($group);
|
||||
$boxId = 'lit-mcp-group-' . self::idPart($group);
|
||||
$modes = [ExposurePolicy::DIRECT => 0, ExposurePolicy::INDIRECT => 0, ExposurePolicy::DISABLED => 0];
|
||||
$mixed = false;
|
||||
foreach ($abilities as $ability) {
|
||||
$modes[$policy->mode($ability)]++;
|
||||
$mixed = $mixed || $policy->hasToolOverride($ability->name());
|
||||
}
|
||||
// Groups start open; one whose tools are all off may start closed. A saved user choice wins.
|
||||
$closed = $closedBoxes === null
|
||||
? $count > 0 && $modes[ExposurePolicy::DISABLED] === $count
|
||||
: in_array($boxId, $closedBoxes, true);
|
||||
|
||||
printf('<div id="%s" class="postbox lit-mcp-group%s">', esc_attr($boxId), $closed ? ' closed' : '');
|
||||
echo '<div class="postbox-header">';
|
||||
printf(
|
||||
'<p><label>%s <select name="%s[%s]">',
|
||||
esc_html__('Ganze Gruppe:', 'wp-lit-mcp'),
|
||||
'<h2 class="hndle">%s <span class="count">(%s)</span> <span class="lit-mcp-group-summary">%s</span></h2>',
|
||||
esc_html($label),
|
||||
esc_html(sprintf(
|
||||
/* translators: %d: number of tools */
|
||||
_n('%d Tool', '%d Tools', $count, 'wp-lit-mcp'),
|
||||
$count
|
||||
)),
|
||||
esc_html($this->summary($modes, $policy->isReadonly($group)))
|
||||
);
|
||||
$this->renderGroupControls($policy, $group, $mixed);
|
||||
printf(
|
||||
'<div class="handle-actions hide-if-no-js"><button type="button" class="handlediv" aria-expanded="%s">'
|
||||
. '<span class="screen-reader-text">%s</span>'
|
||||
. '<span class="toggle-indicator" aria-hidden="true"></span></button></div>',
|
||||
$closed ? 'false' : 'true',
|
||||
esc_html(sprintf(
|
||||
/* translators: %s: group label */
|
||||
__('Gruppe ein-/ausblenden: %s', 'wp-lit-mcp'),
|
||||
$label
|
||||
))
|
||||
);
|
||||
echo '</div><div class="inside">';
|
||||
|
||||
printf(
|
||||
'<table class="widefat striped lit-mcp-tools-table"><thead><tr>'
|
||||
. '<th scope="col" class="column-tool">%s</th><th scope="col" class="column-kind">%s</th>'
|
||||
. '<th scope="col" class="column-mode">%s</th></tr></thead><tbody>',
|
||||
esc_html__('Tool', 'wp-lit-mcp'),
|
||||
esc_html__('Art', 'wp-lit-mcp'),
|
||||
esc_html__('Einstellung', 'wp-lit-mcp')
|
||||
);
|
||||
$groupMode = $policy->groupMode($group);
|
||||
foreach ($abilities as $ability) {
|
||||
$this->renderTool($policy, $ability, $groupMode !== null);
|
||||
}
|
||||
echo '</tbody></table></div></div>';
|
||||
}
|
||||
|
||||
/** @param array<string, int> $modes */
|
||||
private function summary(array $modes, bool $readonly): string
|
||||
{
|
||||
$summary = sprintf(
|
||||
/* translators: 1: number of direct tools, 2: indirect, 3: off */
|
||||
__('%1$d direkt · %2$d indirekt · %3$d aus', 'wp-lit-mcp'),
|
||||
$modes[ExposurePolicy::DIRECT],
|
||||
$modes[ExposurePolicy::INDIRECT],
|
||||
$modes[ExposurePolicy::DISABLED]
|
||||
);
|
||||
|
||||
return $readonly ? __('nur lesen', 'wp-lit-mcp') . ' · ' . $summary : $summary;
|
||||
}
|
||||
|
||||
private function renderGroupControls(ExposurePolicy $policy, string $group, bool $mixed): void
|
||||
{
|
||||
$selectId = 'lit-mcp-group-mode-' . self::idPart($group);
|
||||
$groupMode = $policy->groupMode($group);
|
||||
|
||||
echo '<div class="lit-mcp-group-controls">';
|
||||
printf(
|
||||
'<span class="lit-mcp-control"><label for="%1$s">%2$s</label> <select name="%3$s[%4$s]" id="%1$s">',
|
||||
esc_attr($selectId),
|
||||
esc_html__('Ganze Gruppe', 'wp-lit-mcp'),
|
||||
esc_attr(ExposurePolicy::OPTION),
|
||||
esc_attr(ExposurePolicy::GROUP_PREFIX . $group)
|
||||
);
|
||||
@@ -150,25 +248,20 @@ final class ToolsTab implements AdminTab
|
||||
esc_html($this->modeLabel($mode))
|
||||
);
|
||||
}
|
||||
echo '</select></label> ';
|
||||
echo '</select></span>';
|
||||
printf(
|
||||
'<label><input type="checkbox" name="%s[%s]" value="1"%s> %s</label>',
|
||||
'<span class="lit-mcp-control"><label><input type="checkbox" name="%s[%s]" value="1"%s> %s</label></span>',
|
||||
esc_attr(ExposurePolicy::OPTION),
|
||||
esc_attr(ExposurePolicy::READONLY_PREFIX . $group),
|
||||
checked($policy->isReadonly($group), true, false),
|
||||
esc_html__('nur lesen (schreibende Tools dieser Gruppe aus)', 'wp-lit-mcp')
|
||||
esc_html__('Nur lesen', 'wp-lit-mcp')
|
||||
);
|
||||
if ($mixed) {
|
||||
echo ' <em>' . esc_html__('gemischt – einzelne Tools haben eigene Einstellungen', 'wp-lit-mcp') . '</em>';
|
||||
echo '<span class="description">'
|
||||
. esc_html__('gemischt – einzelne Tools haben eigene Einstellungen', 'wp-lit-mcp')
|
||||
. '</span>';
|
||||
}
|
||||
echo '</p>';
|
||||
|
||||
echo '<table class="widefat striped"><thead><tr><th>' . esc_html__('Tool', 'wp-lit-mcp') . '</th><th>'
|
||||
. esc_html__('Einstellung', 'wp-lit-mcp') . '</th></tr></thead><tbody>';
|
||||
foreach ($abilities as $ability) {
|
||||
$this->renderTool($policy, $ability, $groupMode !== null);
|
||||
}
|
||||
echo '</tbody></table></details>';
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
private function renderTool(ExposurePolicy $policy, Ability $ability, bool $groupOverride): void
|
||||
@@ -177,11 +270,23 @@ final class ToolsTab implements AdminTab
|
||||
$own = $policy->toolOverride($name);
|
||||
$locked = $policy->disabledByReadonly($ability);
|
||||
$required = in_array($name, ExposurePolicy::REQUIRED, true);
|
||||
$selectId = 'lit-mcp-tool-' . self::idPart($name);
|
||||
|
||||
printf(
|
||||
'<tr><td><strong>%s</strong><br><code>%s</code></td><td><select name="%s[%s]"%s>',
|
||||
'<tr><td class="column-tool"><strong>%1$s</strong><br><span class="lit-mcp-tool-name">%2$s</span></td>'
|
||||
. '<td class="column-kind">%3$s</td><td class="column-mode">'
|
||||
. '<label class="screen-reader-text" for="%4$s">%5$s</label>'
|
||||
. '<select name="%6$s[%7$s]"%8$s id="%4$s">',
|
||||
esc_html($ability->label()),
|
||||
esc_html($name),
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in kind().
|
||||
$this->kind($ability),
|
||||
esc_attr($selectId),
|
||||
esc_html(sprintf(
|
||||
/* translators: %s: tool label */
|
||||
__('Einstellung für %s', 'wp-lit-mcp'),
|
||||
$ability->label()
|
||||
)),
|
||||
esc_attr(ExposurePolicy::OPTION),
|
||||
esc_attr($name),
|
||||
$locked ? ' disabled' : ''
|
||||
@@ -220,17 +325,41 @@ final class ToolsTab implements AdminTab
|
||||
}
|
||||
|
||||
$note = match (true) {
|
||||
$locked => __('durch ‚nur lesen‘ aus', 'wp-lit-mcp'),
|
||||
$locked => __('Durch „Nur lesen“ aus', 'wp-lit-mcp'),
|
||||
$own === null && $policy->groupModeAdjusted($ability)
|
||||
=> __('„indirekt“ geht hier nicht – bleibt direkt', 'wp-lit-mcp'),
|
||||
default => '',
|
||||
};
|
||||
if ($note !== '') {
|
||||
echo ' <em>' . esc_html($note) . '</em>';
|
||||
echo '<p class="description">' . esc_html($note) . '</p>';
|
||||
}
|
||||
echo '</td></tr>';
|
||||
}
|
||||
|
||||
/** "liest" / "schreibt" / "löscht" from the ability's annotations, with a dashicon. */
|
||||
private function kind(Ability $ability): string
|
||||
{
|
||||
$annotations = $ability->annotations();
|
||||
[$icon, $text] = match (true) {
|
||||
($annotations['destructive'] ?? false) === true => ['trash', __('löscht', 'wp-lit-mcp')],
|
||||
($annotations['readonly'] ?? false) === true => ['visibility', __('liest', 'wp-lit-mcp')],
|
||||
default => ['edit', __('schreibt', 'wp-lit-mcp')],
|
||||
};
|
||||
|
||||
return sprintf(
|
||||
'<span class="lit-mcp-kind" title="%2$s"><span class="dashicons dashicons-%1$s" aria-hidden="true"></span>'
|
||||
. ' <span class="lit-mcp-kind-text">%2$s</span></span>',
|
||||
esc_attr($icon),
|
||||
esc_html($text)
|
||||
);
|
||||
}
|
||||
|
||||
/** Safe fragment for an HTML id from a group id or tool name ("lit/guide" → "lit-guide"). */
|
||||
private static function idPart(string $value): string
|
||||
{
|
||||
return trim((string) preg_replace('/[^a-z0-9]+/', '-', strtolower($value)), '-');
|
||||
}
|
||||
|
||||
private function groupLabel(string $group): string
|
||||
{
|
||||
return match ($group) {
|
||||
@@ -248,7 +377,7 @@ final class ToolsTab implements AdminTab
|
||||
return match ($mode) {
|
||||
ExposurePolicy::DIRECT => __('direkt', 'wp-lit-mcp'),
|
||||
ExposurePolicy::INDIRECT => __('indirekt', 'wp-lit-mcp'),
|
||||
default => __('deaktiviert', 'wp-lit-mcp'),
|
||||
default => __('aus', 'wp-lit-mcp'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user