423 lines
15 KiB
PHP
423 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Grav\Plugin\Api\Services;
|
|
|
|
use Grav\Common\Grav;
|
|
use Grav\Common\User\Interfaces\UserInterface;
|
|
use Grav\Plugin\Api\PermissionResolver;
|
|
use RocketTheme\Toolbox\Event\Event;
|
|
use RocketTheme\Toolbox\File\YamlFile;
|
|
|
|
/**
|
|
* Resolves the final dashboard widget list for a given user by merging:
|
|
* 1. Built-in core widget registry
|
|
* 2. Plugin-contributed widgets via onApiDashboardWidgets
|
|
* 3. Site layout (super-admin defaults — visibility floor)
|
|
* 4. User layout (per-user overrides — order, size, visible)
|
|
*
|
|
* Site-hidden widgets are stripped before user layout is applied; users can
|
|
* never re-enable a widget the site admin has turned off.
|
|
*/
|
|
class DashboardLayoutResolver
|
|
{
|
|
public const SITE_CONFIG_FILE = 'admin-next.yaml';
|
|
public const VALID_SIZES = ['xs', 'sm', 'md', 'lg', 'xl'];
|
|
|
|
public function __construct(
|
|
private readonly Grav $grav,
|
|
private readonly PermissionResolver $permissions,
|
|
) {}
|
|
|
|
/**
|
|
* Built-in core widgets shipped with admin-next.
|
|
*
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function coreRegistry(): array
|
|
{
|
|
return [
|
|
[
|
|
'id' => 'core.stats',
|
|
'source' => 'core',
|
|
'label' => 'ADMIN_NEXT.DASHBOARD.WIDGETS.STATS',
|
|
'icon' => 'BarChart3',
|
|
'sizes' => ['md', 'lg', 'xl'],
|
|
'defaultSize' => 'xl',
|
|
'authorize' => 'api.system.read',
|
|
'priority' => 100,
|
|
],
|
|
[
|
|
'id' => 'core.popularity',
|
|
'source' => 'core',
|
|
'label' => 'ADMIN_NEXT.DASHBOARD.WIDGETS.POPULARITY',
|
|
'icon' => 'TrendingUp',
|
|
'sizes' => ['md', 'lg', 'xl'],
|
|
'defaultSize' => 'lg',
|
|
'authorize' => 'api.system.read',
|
|
'priority' => 90,
|
|
],
|
|
[
|
|
'id' => 'core.system-health',
|
|
'source' => 'core',
|
|
'label' => 'ADMIN_NEXT.DASHBOARD.WIDGETS.SYSTEM_HEALTH',
|
|
'icon' => 'Activity',
|
|
'sizes' => ['sm', 'md', 'lg'],
|
|
'defaultSize' => 'sm',
|
|
'authorize' => 'api.system.read',
|
|
'priority' => 80,
|
|
],
|
|
[
|
|
'id' => 'core.recent-pages',
|
|
'source' => 'core',
|
|
'label' => 'ADMIN_NEXT.DASHBOARD.WIDGETS.RECENT_PAGES',
|
|
'icon' => 'FileText',
|
|
'sizes' => ['sm', 'md'],
|
|
'defaultSize' => 'md',
|
|
'authorize' => 'api.pages.read',
|
|
'priority' => 70,
|
|
],
|
|
[
|
|
'id' => 'core.top-pages',
|
|
'source' => 'core',
|
|
'label' => 'ADMIN_NEXT.DASHBOARD.WIDGETS.TOP_PAGES',
|
|
'icon' => 'Flame',
|
|
'sizes' => ['sm', 'md'],
|
|
'defaultSize' => 'sm',
|
|
'authorize' => 'api.system.read',
|
|
'priority' => 60,
|
|
],
|
|
[
|
|
'id' => 'core.backups',
|
|
'source' => 'core',
|
|
'label' => 'ADMIN_NEXT.DASHBOARD.WIDGETS.BACKUPS',
|
|
'icon' => 'Archive',
|
|
'sizes' => ['sm', 'md'],
|
|
'defaultSize' => 'sm',
|
|
'authorize' => 'api.system.read',
|
|
'priority' => 50,
|
|
],
|
|
[
|
|
'id' => 'core.notifications',
|
|
'source' => 'core',
|
|
'label' => 'ADMIN_NEXT.DASHBOARD.WIDGETS.NOTIFICATIONS',
|
|
'icon' => 'Bell',
|
|
'sizes' => ['sm', 'md', 'lg'],
|
|
'defaultSize' => 'md',
|
|
'authorize' => 'api.system.read',
|
|
'priority' => 40,
|
|
],
|
|
[
|
|
'id' => 'core.news-feed',
|
|
'source' => 'core',
|
|
'label' => 'ADMIN_NEXT.DASHBOARD.WIDGETS.NEWS_FEED',
|
|
'icon' => 'Rss',
|
|
'sizes' => ['sm', 'md', 'lg'],
|
|
'defaultSize' => 'md',
|
|
'authorize' => 'api.system.read',
|
|
'priority' => 30,
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Collect plugin-contributed widgets via the onApiDashboardWidgets event.
|
|
*
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function pluginRegistry(UserInterface $user): array
|
|
{
|
|
$event = new Event(['widgets' => [], 'user' => $user]);
|
|
$this->grav->fireEvent('onApiDashboardWidgets', $event);
|
|
|
|
$items = [];
|
|
foreach ($event['widgets'] as $widget) {
|
|
if (!is_array($widget) || empty($widget['id'])) {
|
|
continue;
|
|
}
|
|
$widget['source'] = 'plugin';
|
|
$items[] = $widget;
|
|
}
|
|
return $items;
|
|
}
|
|
|
|
/**
|
|
* Read the site-wide dashboard layout from user/config/admin-next.yaml.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function siteLayout(): array
|
|
{
|
|
$path = $this->siteConfigFilePath();
|
|
if (!$path || !is_file($path)) {
|
|
return [];
|
|
}
|
|
$content = (array) YamlFile::instance($path)->content();
|
|
$layout = $content['dashboard']['site_layout'] ?? [];
|
|
return is_array($layout) ? $layout : [];
|
|
}
|
|
|
|
/**
|
|
* Read this user's saved dashboard layout from their account YAML.
|
|
*
|
|
* Storage location is the top-level `admin_next.dashboard` key. Older
|
|
* builds wrote to `state.admin_next.dashboard`, which collided with
|
|
* Grav's account-state string (`state: enabled` / `state: disabled`)
|
|
* and caused affected users to render as Disabled in user lists.
|
|
* `migrateLegacyState()` lifts that legacy data out and restores the
|
|
* account-state string on first read.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function userLayout(UserInterface $user): array
|
|
{
|
|
$this->migrateLegacyState($user);
|
|
|
|
$adminNext = $user->get('admin_next');
|
|
if (!is_array($adminNext)) {
|
|
return [];
|
|
}
|
|
$layout = $adminNext['dashboard'] ?? [];
|
|
return is_array($layout) ? $layout : [];
|
|
}
|
|
|
|
/**
|
|
* One-time migration: if `state` was clobbered by an older build with a
|
|
* map containing `admin_next.dashboard`, lift the dashboard layout out
|
|
* to the top-level `admin_next.dashboard` key and restore `state` to
|
|
* the standard `enabled` / `disabled` string.
|
|
*/
|
|
private function migrateLegacyState(UserInterface $user): bool
|
|
{
|
|
$state = $user->get('state');
|
|
if (!is_array($state)) {
|
|
return false;
|
|
}
|
|
|
|
$legacyDashboard = $state['admin_next']['dashboard'] ?? null;
|
|
if (is_array($legacyDashboard)) {
|
|
$adminNext = $user->get('admin_next');
|
|
$adminNext = is_array($adminNext) ? $adminNext : [];
|
|
// New location wins if both are present (shouldn't happen, but
|
|
// be defensive — the new write path is authoritative).
|
|
if (!isset($adminNext['dashboard'])) {
|
|
$adminNext['dashboard'] = $legacyDashboard;
|
|
$user->set('admin_next', $adminNext);
|
|
}
|
|
}
|
|
|
|
// Restore the account-state string. If a legacy install ever wrote
|
|
// an explicit `state.enabled: false`, honor it; otherwise default
|
|
// to `enabled` since the account exists and was being used.
|
|
$restored = ($state['enabled'] ?? null) === false ? 'disabled' : 'enabled';
|
|
$user->set('state', $restored);
|
|
$user->save();
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Resolve the final widget list for a user.
|
|
*
|
|
* Returns the merged list with each widget annotated with its effective
|
|
* `visible`, `size`, and `order`, plus a flag indicating whether the
|
|
* widget was hidden by the site admin (in which case the user cannot
|
|
* override).
|
|
*
|
|
* @return array{
|
|
* widgets: array<int, array<string, mixed>>,
|
|
* user_layout: array<string, mixed>,
|
|
* site_layout: array<string, mixed>,
|
|
* can_edit_site: bool
|
|
* }
|
|
*/
|
|
public function resolve(UserInterface $user, bool $isSuperAdmin): array
|
|
{
|
|
$registry = array_merge($this->coreRegistry(), $this->pluginRegistry($user));
|
|
|
|
// Permission filter
|
|
$available = [];
|
|
foreach ($registry as $widget) {
|
|
$authorize = $widget['authorize'] ?? null;
|
|
if ($authorize !== null && !$isSuperAdmin && !(bool) $this->permissions->resolve($user, $authorize)) {
|
|
continue;
|
|
}
|
|
$available[$widget['id']] = $widget;
|
|
}
|
|
|
|
$siteLayout = $this->siteLayout();
|
|
$userLayout = $this->userLayout($user);
|
|
|
|
$siteEntries = $this->indexEntries($siteLayout['widgets'] ?? []);
|
|
$userEntries = $this->indexEntries($userLayout['widgets'] ?? []);
|
|
|
|
$merged = [];
|
|
$defaultOrder = 0;
|
|
foreach ($available as $id => $widget) {
|
|
$siteEntry = $siteEntries[$id] ?? null;
|
|
$userEntry = $userEntries[$id] ?? null;
|
|
|
|
$siteHidden = $siteEntry !== null && ($siteEntry['visible'] ?? true) === false;
|
|
|
|
// If site admin hid this widget, drop it entirely from the user's view.
|
|
if ($siteHidden) {
|
|
continue;
|
|
}
|
|
|
|
$size = $userEntry['size'] ?? $siteEntry['size'] ?? $widget['defaultSize'];
|
|
if (!in_array($size, self::VALID_SIZES, true)) {
|
|
$size = $widget['defaultSize'];
|
|
}
|
|
// Coerce to a size the widget supports
|
|
if (!in_array($size, $widget['sizes'], true)) {
|
|
$size = $widget['defaultSize'];
|
|
}
|
|
|
|
$visible = $userEntry !== null
|
|
? (bool) ($userEntry['visible'] ?? true)
|
|
: (bool) ($siteEntry['visible'] ?? true);
|
|
|
|
$order = $userEntry['order']
|
|
?? $siteEntry['order']
|
|
?? (1000 - (int) ($widget['priority'] ?? 0)) * 10 + $defaultOrder++;
|
|
|
|
$widget['visible'] = $visible;
|
|
$widget['size'] = $size;
|
|
$widget['order'] = (int) $order;
|
|
// Strip server-only annotation
|
|
unset($widget['authorize']);
|
|
$merged[] = $widget;
|
|
}
|
|
|
|
usort($merged, static fn($a, $b) => $a['order'] <=> $b['order']);
|
|
|
|
return [
|
|
'widgets' => $merged,
|
|
'user_layout' => $userLayout,
|
|
'site_layout' => $siteLayout,
|
|
'can_edit_site' => $isSuperAdmin,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Persist a user's layout to their account YAML under admin_next.dashboard.
|
|
*
|
|
* Note: this used to write to `state.admin_next.dashboard`, which
|
|
* collided with Grav's `state: enabled|disabled` account-state field.
|
|
* Legacy data is migrated on read by `migrateLegacyState()`.
|
|
*
|
|
* @param array<string, mixed> $layout
|
|
*/
|
|
public function saveUserLayout(UserInterface $user, array $layout): void
|
|
{
|
|
$this->migrateLegacyState($user);
|
|
|
|
$adminNext = $user->get('admin_next');
|
|
$adminNext = is_array($adminNext) ? $adminNext : [];
|
|
$adminNext['dashboard'] = $this->normalizeLayout($layout);
|
|
$user->set('admin_next', $adminNext);
|
|
$user->save();
|
|
}
|
|
|
|
/**
|
|
* Persist the site-wide layout to user/config/admin-next.yaml.
|
|
*
|
|
* @param array<string, mixed> $layout
|
|
*/
|
|
public function saveSiteLayout(array $layout): void
|
|
{
|
|
$path = $this->siteConfigFilePath(true);
|
|
if (!$path) {
|
|
throw new \RuntimeException('Unable to resolve user/config path for admin-next.yaml.');
|
|
}
|
|
$file = YamlFile::instance($path);
|
|
$content = (array) $file->content();
|
|
$content['dashboard'] = is_array($content['dashboard'] ?? null) ? $content['dashboard'] : [];
|
|
$content['dashboard']['site_layout'] = $this->normalizeLayout($layout);
|
|
$file->content($content);
|
|
$file->save();
|
|
|
|
// Make the saved layout visible to the running config in this request
|
|
$config = $this->grav['config'] ?? null;
|
|
if ($config) {
|
|
$config->set('admin-next.dashboard.site_layout', $content['dashboard']['site_layout']);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Normalize a layout payload, dropping unknown keys and bad types.
|
|
*
|
|
* @param array<string, mixed> $layout
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function normalizeLayout(array $layout): array
|
|
{
|
|
$out = [];
|
|
$preset = $layout['preset'] ?? 'custom';
|
|
if (is_string($preset) && in_array($preset, ['default', 'minimal', 'compact', 'custom'], true)) {
|
|
$out['preset'] = $preset;
|
|
} else {
|
|
$out['preset'] = 'custom';
|
|
}
|
|
|
|
$widgets = [];
|
|
foreach ((array) ($layout['widgets'] ?? []) as $entry) {
|
|
if (!is_array($entry) || empty($entry['id']) || !is_string($entry['id'])) {
|
|
continue;
|
|
}
|
|
$size = $entry['size'] ?? null;
|
|
$widgets[] = [
|
|
'id' => $entry['id'],
|
|
'visible' => array_key_exists('visible', $entry) ? (bool) $entry['visible'] : true,
|
|
'size' => is_string($size) && in_array($size, self::VALID_SIZES, true) ? $size : null,
|
|
'order' => isset($entry['order']) ? (int) $entry['order'] : 0,
|
|
];
|
|
}
|
|
$out['widgets'] = $widgets;
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $entries
|
|
* @return array<string, array<string, mixed>>
|
|
*/
|
|
private function indexEntries(mixed $entries): array
|
|
{
|
|
if (!is_array($entries)) {
|
|
return [];
|
|
}
|
|
$indexed = [];
|
|
foreach ($entries as $entry) {
|
|
if (is_array($entry) && !empty($entry['id']) && is_string($entry['id'])) {
|
|
$indexed[$entry['id']] = $entry;
|
|
}
|
|
}
|
|
return $indexed;
|
|
}
|
|
|
|
/**
|
|
* Resolve the absolute path to user/config/admin-next.yaml.
|
|
*/
|
|
private function siteConfigFilePath(bool $createDir = false): ?string
|
|
{
|
|
$locator = $this->grav['locator'] ?? null;
|
|
if ($locator === null) {
|
|
return null;
|
|
}
|
|
$userConfigDir = $locator->findResource('user://config', true) ?: null;
|
|
if ($userConfigDir === null) {
|
|
$userPath = $locator->findResource('user://', true);
|
|
if ($userPath && $createDir) {
|
|
$userConfigDir = $userPath . '/config';
|
|
if (!is_dir($userConfigDir)) {
|
|
mkdir($userConfigDir, 0775, true);
|
|
}
|
|
}
|
|
}
|
|
if (!$userConfigDir) {
|
|
return null;
|
|
}
|
|
return $userConfigDir . '/' . self::SITE_CONFIG_FILE;
|
|
}
|
|
}
|