mirror of
https://code.castopod.org/adaures/castopod
synced 2025-04-22 16:51:20 +00:00
feat(plugins): activate / deactivate plugin using settings table
+ load plugin icon + add pagination + autoload plugins in Config/Autoload.php to handle plugin i18n + style plugin cards
This commit is contained in:
parent
2f517fde47
commit
0eba234628
@ -55,7 +55,6 @@ class Autoload extends AutoloadConfig
|
||||
'Modules\PremiumPodcasts' => ROOTPATH . 'modules/PremiumPodcasts/',
|
||||
'Modules\Update' => ROOTPATH . 'modules/Update/',
|
||||
'Modules\WebSub' => ROOTPATH . 'modules/WebSub/',
|
||||
'Plugins' => ROOTPATH . 'plugins',
|
||||
'Themes' => ROOTPATH . 'themes',
|
||||
'ViewComponents' => APPPATH . 'Libraries/ViewComponents/',
|
||||
'ViewThemes' => APPPATH . 'Libraries/ViewThemes/',
|
||||
@ -111,4 +110,20 @@ class Autoload extends AutoloadConfig
|
||||
* @var list<string>
|
||||
*/
|
||||
public $helpers = ['auth', 'setting', 'icons'];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// load plugins namespaces
|
||||
$pluginsPaths = glob(ROOTPATH . '/plugins/*', GLOB_ONLYDIR);
|
||||
|
||||
if (! $pluginsPaths) {
|
||||
$pluginsPaths = [];
|
||||
}
|
||||
|
||||
foreach ($pluginsPaths as $pluginPath) {
|
||||
$this->psr4[sprintf('Plugins\%s', basename($pluginPath))] = $pluginPath;
|
||||
}
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
}
|
||||
|
@ -16,6 +16,7 @@ use CodeIgniter\I18n\Time;
|
||||
use Config\Mimes;
|
||||
use Modules\Media\Entities\Chapters;
|
||||
use Modules\Media\Entities\Transcript;
|
||||
use Modules\Plugins\Plugins;
|
||||
use Modules\PremiumPodcasts\Entities\Subscription;
|
||||
|
||||
if (! function_exists('get_rss_feed')) {
|
||||
@ -31,6 +32,7 @@ if (! function_exists('get_rss_feed')) {
|
||||
Subscription $subscription = null,
|
||||
string $token = null
|
||||
): string {
|
||||
/** @var Plugins $plugins */
|
||||
$plugins = service('plugins');
|
||||
|
||||
$episodes = $podcast->episodes;
|
||||
@ -71,8 +73,6 @@ if (! function_exists('get_rss_feed')) {
|
||||
$channel->addChild('generator', 'Castopod - https://castopod.org/');
|
||||
$channel->addChild('docs', 'https://cyber.harvard.edu/rss/rss.html');
|
||||
|
||||
$plugins->runHook('setChannelTag', [$podcast, $channel]);
|
||||
|
||||
if ($podcast->guid === '') {
|
||||
// FIXME: guid shouldn't be empty here as it should be filled upon Podcast creation
|
||||
$uuid = service('uuid');
|
||||
@ -297,6 +297,9 @@ if (! function_exists('get_rss_feed')) {
|
||||
], $channel);
|
||||
}
|
||||
|
||||
// run plugins hook at the end
|
||||
$plugins->setChannelTag($podcast, $channel);
|
||||
|
||||
foreach ($episodes as $episode) {
|
||||
if ($episode->is_premium && ! $subscription instanceof Subscription) {
|
||||
continue;
|
||||
@ -456,6 +459,8 @@ if (! function_exists('get_rss_feed')) {
|
||||
'elements' => $episode->custom_rss,
|
||||
], $item);
|
||||
}
|
||||
|
||||
$plugins->setItemTag($episode, $item);
|
||||
}
|
||||
|
||||
return $rss->asXML();
|
||||
|
@ -23,6 +23,7 @@ return [
|
||||
'add' => 'add',
|
||||
'new' => 'new',
|
||||
'edit' => 'edit',
|
||||
'plugins' => 'plugins',
|
||||
'persons' => 'persons',
|
||||
'publish' => 'publish',
|
||||
'publish-edit' => 'edit publication',
|
||||
|
@ -20,6 +20,8 @@ return [
|
||||
'podcast-create' => 'New podcast',
|
||||
'all-podcast-imports' => 'All Podcast imports',
|
||||
'podcast-imports-add' => 'Import a podcast',
|
||||
'plugins' => 'Plugins',
|
||||
'plugins-installed' => 'Installed',
|
||||
'persons' => 'Persons',
|
||||
'person-list' => 'All persons',
|
||||
'person-create' => 'New person',
|
||||
|
@ -107,6 +107,7 @@ class AuthGroups extends ShieldAuthGroups
|
||||
public array $instanceBasePermissions = [
|
||||
'admin.access',
|
||||
'admin.settings',
|
||||
'plugins.manage',
|
||||
'users.manage',
|
||||
'persons.manage',
|
||||
'pages.manage',
|
||||
@ -122,6 +123,7 @@ class AuthGroups extends ShieldAuthGroups
|
||||
public array $instanceMatrix = [
|
||||
'superadmin' => [
|
||||
'admin.*',
|
||||
'plugins.*',
|
||||
'podcasts.*',
|
||||
'users.manage',
|
||||
'persons.manage',
|
||||
|
@ -7,13 +7,49 @@ namespace Modules\Plugins;
|
||||
use App\Entities\Episode;
|
||||
use App\Entities\Podcast;
|
||||
use App\Libraries\SimpleRSSElement;
|
||||
use CodeIgniter\I18n\Time;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* @property string $name
|
||||
* @property string $description
|
||||
* @property string $version
|
||||
* @property string $website
|
||||
* @property Time $releaseDate
|
||||
* @property string $author
|
||||
* @property string $license
|
||||
* @property string $compatible
|
||||
* @property string[] $keywords
|
||||
* @property string $iconSrc
|
||||
*/
|
||||
abstract class BasePlugin implements PluginInterface
|
||||
{
|
||||
public function __construct()
|
||||
protected bool $active;
|
||||
|
||||
public function __construct(
|
||||
protected string $key,
|
||||
protected string $filePath
|
||||
) {
|
||||
$pluginDirectory = dirname($filePath);
|
||||
|
||||
$manifest = $this->loadManifest($pluginDirectory . '/manifest.json');
|
||||
|
||||
foreach ($manifest as $key => $value) {
|
||||
$this->{$key} = $value;
|
||||
}
|
||||
|
||||
// check that plugin is active
|
||||
$this->active = get_plugin_option($this->key, 'active') ?? false;
|
||||
|
||||
$this->iconSrc = $this->loadIcon($pluginDirectory . '/icon.svg');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string>|string $value
|
||||
*/
|
||||
public function __set(string $name, array|string $value): void
|
||||
{
|
||||
// load metadata from json
|
||||
// load name, description, etc.
|
||||
$this->{$name} = $name === 'releaseDate' ? Time::createFromFormat('Y-m-d', $value) : $value;
|
||||
}
|
||||
|
||||
public function init(): void
|
||||
@ -30,4 +66,113 @@ abstract class BasePlugin implements PluginInterface
|
||||
public function setItemTag(Episode $episode, SimpleRSSElement $item): void
|
||||
{
|
||||
}
|
||||
|
||||
final public function isActive(): bool
|
||||
{
|
||||
return $this->active;
|
||||
}
|
||||
|
||||
final public function getKey(): string
|
||||
{
|
||||
return $this->key;
|
||||
}
|
||||
|
||||
final public function getName(): string
|
||||
{
|
||||
$key = sprintf('Plugin.%s.name', $this->key);
|
||||
/** @var string $name */
|
||||
$name = lang($key);
|
||||
|
||||
if ($name === $key) {
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
final public function getDescription(): string
|
||||
{
|
||||
$key = sprintf('Plugin.%s.description', $this->key);
|
||||
|
||||
/** @var string $description */
|
||||
$description = lang($key);
|
||||
|
||||
if ($description === $key) {
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
return $description;
|
||||
}
|
||||
|
||||
final protected function getOption(string $option): mixed
|
||||
{
|
||||
return get_plugin_option($this->key, $option);
|
||||
}
|
||||
|
||||
final protected function setOption(string $option, mixed $value = null): void
|
||||
{
|
||||
set_plugin_option($this->key, $option, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string|list<string>>
|
||||
*/
|
||||
private function loadManifest(string $path): array
|
||||
{
|
||||
// TODO: cache manifest data
|
||||
|
||||
$manifestContents = file_get_contents($path);
|
||||
|
||||
if (! $manifestContents) {
|
||||
throw new RuntimeException('manifest file not found!');
|
||||
}
|
||||
|
||||
/** @var array<mixed>|null $manifest */
|
||||
$manifest = json_decode($manifestContents, true);
|
||||
|
||||
if ($manifest === null) {
|
||||
throw new RuntimeException('manifest.json is not a valid JSON', 1);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'name' => 'required|max_length[32]',
|
||||
'version' => 'required|regex_match[/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/]',
|
||||
'compatible' => 'required|in_list[1.0]',
|
||||
'description' => 'max_length[128]',
|
||||
'releaseDate' => 'valid_date[Y-m-d]',
|
||||
'license' => 'in_list[MIT]',
|
||||
'author.name' => 'max_length[32]',
|
||||
'author.email' => 'valid_email',
|
||||
'author.url' => 'valid_url_strict',
|
||||
'website' => 'valid_url_strict',
|
||||
'keywords.*' => 'in_list[seo,podcasting20,analytics]',
|
||||
];
|
||||
|
||||
$validation = service('validation');
|
||||
|
||||
$validation->setRules($rules);
|
||||
|
||||
if (! $validation->run($manifest)) {
|
||||
dd($validation->getErrors());
|
||||
}
|
||||
|
||||
return $validation->getValidated();
|
||||
}
|
||||
|
||||
private function loadIcon(string $path): string
|
||||
{
|
||||
// TODO: cache icon
|
||||
$svgIcon = @file_get_contents($path);
|
||||
|
||||
if (! $svgIcon) {
|
||||
return "data:image/svg+xml;utf8,%3Csvg xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg' viewBox='0 0 64 64'%3E%3Cpath fill='%2300564A' d='M0 0h64v64H0z'%2F%3E%3Cpath fill='%23E7F9E4' d='M25.3 18.7a5 5 0 1 1 9.7 1.6h7c1 0 1.7.8 1.7 1.7v7a5 5 0 1 1 0 9.4v7c0 .9-.8 1.6-1.7 1.6H18.7c-1 0-1.7-.7-1.7-1.7V22c0-1 .7-1.7 1.7-1.7h7a5 5 0 0 1-.4-1.6Z'%2F%3E%3C%2Fsvg%3E";
|
||||
}
|
||||
|
||||
$encodedIcon = rawurlencode(str_replace(["\r", "\n"], ' ', $svgIcon));
|
||||
return 'data:image/svg+xml;utf8,' . str_replace(
|
||||
['%20', '%22', '%27', '%3D'],
|
||||
[' ', "'", "'", '='],
|
||||
$encodedIcon
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -12,9 +12,19 @@ $routes->group(
|
||||
'namespace' => 'Modules\Plugins\Controllers',
|
||||
],
|
||||
static function ($routes): void {
|
||||
$routes->get('plugins', 'PluginsController', [
|
||||
'as' => 'plugins',
|
||||
'filter' => 'permission:podcasts.import',
|
||||
]);
|
||||
$routes->group('plugins', static function ($routes): void {
|
||||
$routes->get('/', 'PluginsController::installed', [
|
||||
'as' => 'plugins-installed',
|
||||
'filter' => 'permission:plugins.manage',
|
||||
]);
|
||||
$routes->post('activate/(:segment)', 'PluginsController::activate/$1', [
|
||||
'as' => 'plugins-activate',
|
||||
'filter' => 'permission:plugins.manage',
|
||||
]);
|
||||
$routes->post('deactivate/(:segment)', 'PluginsController::deactivate/$1', [
|
||||
'as' => 'plugins-deactivate',
|
||||
'filter' => 'permission:plugins.manage',
|
||||
]);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
@ -1,17 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Modules\Admin\Controllers\BaseController;
|
||||
|
||||
class Controller extends BaseController
|
||||
{
|
||||
public function index(): string
|
||||
{
|
||||
$plugins = service('plugins');
|
||||
|
||||
return view('plugins', [
|
||||
'installedPlugins' => $plugins->getInstalled(),
|
||||
]);
|
||||
}
|
||||
}
|
46
modules/Plugins/Controllers/PluginsController.php
Normal file
46
modules/Plugins/Controllers/PluginsController.php
Normal file
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Plugins\Controllers;
|
||||
|
||||
use CodeIgniter\HTTP\RedirectResponse;
|
||||
use Modules\Admin\Controllers\BaseController;
|
||||
use Modules\Plugins\Plugins;
|
||||
|
||||
class PluginsController extends BaseController
|
||||
{
|
||||
public function installed(): string
|
||||
{
|
||||
/** @var Plugins $plugins */
|
||||
$plugins = service('plugins');
|
||||
|
||||
$pager = service('pager');
|
||||
|
||||
$page = (int) ($this->request->getGet('page') ?? 1);
|
||||
$perPage = 10;
|
||||
$total = $plugins->getInstalledCount();
|
||||
|
||||
$pager_links = $pager->makeLinks($page, $perPage, $total);
|
||||
|
||||
return view('plugins/installed', [
|
||||
'total' => $total,
|
||||
'plugins' => $plugins->getPlugins($page, $perPage),
|
||||
'pager_links' => $pager_links,
|
||||
]);
|
||||
}
|
||||
|
||||
public function activate(string $pluginKey): RedirectResponse
|
||||
{
|
||||
service('plugins')->activate($pluginKey);
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
public function deactivate(string $pluginKey): RedirectResponse
|
||||
{
|
||||
service('plugins')->deactivate($pluginKey);
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
}
|
24
modules/Plugins/Helpers/plugins_helper.php
Normal file
24
modules/Plugins/Helpers/plugins_helper.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if (! function_exists('get_plugin_option')) {
|
||||
function get_plugin_option(string $pluginKey, string $option): mixed
|
||||
{
|
||||
$key = sprintf('Plugins.%s', $option);
|
||||
$context = sprintf('plugin:%s', $pluginKey);
|
||||
|
||||
return setting()->get($key, $context);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('set_plugin_option')) {
|
||||
function set_plugin_option(string $pluginKey, string $option, mixed $value = null): void
|
||||
{
|
||||
$key = sprintf('Plugins.%s', $option);
|
||||
$context = sprintf('plugin:%s', $pluginKey);
|
||||
|
||||
setting()
|
||||
->set($key, $value, $context);
|
||||
}
|
||||
}
|
22
modules/Plugins/Language/en/Plugins.php
Normal file
22
modules/Plugins/Language/en/Plugins.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @copyright 2022 Ad Aures
|
||||
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
|
||||
* @link https://castopod.org/
|
||||
*/
|
||||
|
||||
return [
|
||||
"installed" => "Installed plugins ({count})",
|
||||
"website" => "Website",
|
||||
"activate" => "Activate",
|
||||
"deactivate" => "Deactivate",
|
||||
"keywords" => [
|
||||
'podcasting20' => 'Podcasting 2.0',
|
||||
'seo' => 'SEO',
|
||||
'analytics' => 'Analytics',
|
||||
'accessibility' => 'Accessibility',
|
||||
],
|
||||
];
|
@ -4,59 +4,111 @@ declare(strict_types=1);
|
||||
|
||||
namespace Modules\Plugins;
|
||||
|
||||
use App\Entities\Episode;
|
||||
use App\Entities\Podcast;
|
||||
use App\Libraries\SimpleRSSElement;
|
||||
|
||||
/**
|
||||
* @method void setChannelTag(Podcast $podcast, SimpleRSSElement $channel)
|
||||
* @method void setItemTag(Episode $episode, SimpleRSSElement $item)
|
||||
*/
|
||||
class Plugins
|
||||
{
|
||||
protected const API_VERSION = '1.0';
|
||||
|
||||
/**
|
||||
* @var array<PluginInterface>
|
||||
* @var list<string>
|
||||
*/
|
||||
protected const HOOKS = ['setChannelTag', 'setItemTag'];
|
||||
|
||||
/**
|
||||
* @var array<BasePlugin>
|
||||
*/
|
||||
protected static array $plugins = [];
|
||||
|
||||
protected static int $installedCount = 0;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
helper('plugins');
|
||||
|
||||
$this->registerPlugins();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<PluginInterface>
|
||||
* @param value-of<static::HOOKS> $name
|
||||
* @param array<mixed> $arguments
|
||||
*/
|
||||
public function getPlugins(): array
|
||||
public function __call(string $name, array $arguments): void
|
||||
{
|
||||
return $this->plugins;
|
||||
if (! in_array($name, static::HOOKS, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->runHook($name, $arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $parameters
|
||||
* @return array<BasePlugin>
|
||||
*/
|
||||
public function runHook(string $name, array $parameters): void
|
||||
public function getPlugins(int $page, int $perPage): array
|
||||
{
|
||||
return array_slice(static::$plugins, (($page - 1) * $perPage), $perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param value-of<static::HOOKS> $name
|
||||
* @param array<mixed> $arguments
|
||||
*/
|
||||
public function runHook(string $name, array $arguments): void
|
||||
{
|
||||
dd(static::$plugins);
|
||||
// only run active plugins' hooks
|
||||
foreach (static::$plugins as $plugin) {
|
||||
$plugin->{$name}(...$parameters);
|
||||
// only run hook on active plugins
|
||||
if ($plugin->isActive()) {
|
||||
$plugin->{$name}(...$arguments);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function activate(string $pluginKey): void
|
||||
{
|
||||
set_plugin_option($pluginKey, 'active', true);
|
||||
}
|
||||
|
||||
public function deactivate(string $pluginKey): void
|
||||
{
|
||||
set_plugin_option($pluginKey, 'active', false);
|
||||
}
|
||||
|
||||
public function getInstalledCount(): int
|
||||
{
|
||||
return static::$installedCount;
|
||||
}
|
||||
|
||||
protected function registerPlugins(): void
|
||||
{
|
||||
// search for plugins in plugins folder
|
||||
$pluginsFiles = glob(ROOTPATH . '/plugins/**/Plugin.php', GLOB_NOSORT);
|
||||
|
||||
if (! $pluginsFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
$locator = service('locator');
|
||||
$pluginsFiles = $locator->search('HelloWorld/Plugin.php');
|
||||
|
||||
// dd($pluginsFiles);
|
||||
|
||||
foreach ($pluginsFiles as $file) {
|
||||
$className = $locator->findQualifiedNameFromPath($file);
|
||||
|
||||
dd($file);
|
||||
if ($className === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$plugin = new $className();
|
||||
if (! $plugin instanceof PluginInterface) {
|
||||
$plugin = new $className(basename(dirname($file)), $file);
|
||||
if (! $plugin instanceof BasePlugin) {
|
||||
continue;
|
||||
}
|
||||
|
||||
static::$plugins[] = $plugin;
|
||||
++static::$installedCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -118,6 +118,7 @@ module.exports = {
|
||||
latestEpisodes: "repeat(5, 1fr)",
|
||||
colorButtons: "repeat(auto-fill, minmax(4rem, 1fr))",
|
||||
platforms: "repeat(auto-fill, minmax(18rem, 1fr))",
|
||||
plugins: "repeat(auto-fill, minmax(20rem, 1fr))",
|
||||
},
|
||||
gridTemplateRows: {
|
||||
admin: "40px 1fr",
|
||||
|
@ -21,6 +21,15 @@ $navigation = [
|
||||
'add-cta' => 'podcast-create',
|
||||
'count-route' => 'podcast-list',
|
||||
],
|
||||
'plugins' => [
|
||||
'icon' => 'puzzle-fill', // @icon('puzzle-fill')
|
||||
'items' => ['plugins-installed'],
|
||||
'items-permissions' => [
|
||||
'plugins-installed' => 'plugins.manage',
|
||||
],
|
||||
'count' => service('plugins')->getInstalledCount(),
|
||||
'count-route' => 'plugins-installed',
|
||||
],
|
||||
'persons' => [
|
||||
'icon' => 'folder-user-fill', // @icon('folder-user-fill')
|
||||
'items' => ['person-list', 'person-create'],
|
||||
|
23
themes/cp_admin/plugins/_plugin.php
Normal file
23
themes/cp_admin/plugins/_plugin.php
Normal file
@ -0,0 +1,23 @@
|
||||
<article class="flex flex-col p-4 rounded-xl bg-elevated border-3 <?= $plugin->isActive() ? 'border-accent-base' : 'border-subtle' ?>">
|
||||
<img class="rounded-full min-w-16 max-w-16 aspect-square" src="<?= $plugin->iconSrc ?>">
|
||||
<div class="flex flex-col mt-2">
|
||||
<h2 class="flex items-center text-xl font-bold font-display gap-x-2"><?= $plugin->getName() ?><span class="px-1 font-mono text-xs rounded-full bg-subtle"><?= $plugin->version ?></span></h2>
|
||||
<p class="text-gray-600"><?= $plugin->getDescription() ?></p>
|
||||
</div>
|
||||
<footer class="flex items-center justify-between mt-4">
|
||||
<a href="<?= $plugin->website ?>" class="inline-flex items-center text-sm font-semibold underline hover:no-underline gap-x-1" target="_blank" rel="noopener noreferrer"><?= icon('link', [
|
||||
'class' => 'text-gray-500',
|
||||
]) . lang('Plugins.website') ?></a>
|
||||
<?php if($plugin->isActive()): ?>
|
||||
<form class="flex justify-end" method="POST" action="<?= route_to('plugins-deactivate', $plugin->getKey()) ?>">
|
||||
<?= csrf_field() ?>
|
||||
<Button type="submit" variant="danger" size="small"><?= lang('Plugins.deactivate') ?></Button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<form class="flex justify-end" method="POST" action="<?= route_to('plugins-activate', $plugin->getKey()) ?>">
|
||||
<?= csrf_field() ?>
|
||||
<Button type="submit" variant="secondary" size="small"><?= lang('Plugins.activate') ?></Button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</footer>
|
||||
</article>
|
24
themes/cp_admin/plugins/installed.php
Normal file
24
themes/cp_admin/plugins/installed.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?= $this->extend('_layout') ?>
|
||||
|
||||
<?= $this->section('title') ?>
|
||||
<?= lang('Plugins.installed', [
|
||||
'count' => $total,
|
||||
]) ?>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->section('pageTitle') ?>
|
||||
<?= lang('Plugins.installed', [
|
||||
'count' => $total,
|
||||
]) ?>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->section('content') ?>
|
||||
<div class="grid gap-4 mb-4 grid-cols-plugins">
|
||||
<?php foreach ($plugins as $plugin) {
|
||||
echo view('plugins/_plugin', [
|
||||
'plugin' => $plugin,
|
||||
]);
|
||||
} ?>
|
||||
</div>
|
||||
<?= $pager_links ?>
|
||||
<?= $this->endSection() ?>
|
Loading…
x
Reference in New Issue
Block a user