mirror of
https://github.com/getgrav/grav-plugin-admin.git
synced 2025-12-16 05:20:31 +01:00
Merge branch 'release/1.10.50'
This commit is contained in:
13
CHANGELOG.md
13
CHANGELOG.md
@@ -1,3 +1,16 @@
|
||||
# v1.10.50
|
||||
## 11/14/2025
|
||||
|
||||
1. [](#new)
|
||||
* Support for 'safe-upgrade' installation
|
||||
* Support for safe-upgrade restore functionality in Tools
|
||||
1. [](#improved)
|
||||
* Improved session expiration/logout handling
|
||||
* Various minor CSS fixes
|
||||
1. [](#bugfix)
|
||||
* Fix for deeply nested sortable fields (at last!)
|
||||
* Restore admin session timeout modal by returning 401 for timed-out AJAX requests
|
||||
|
||||
# v1.10.49.1
|
||||
## 09/03/2025
|
||||
|
||||
|
||||
24
admin.php
24
admin.php
@@ -32,6 +32,7 @@ use Grav\Plugin\Admin\Popularity;
|
||||
use Grav\Plugin\Admin\Router;
|
||||
use Grav\Plugin\Admin\Themes;
|
||||
use Grav\Plugin\Admin\AdminController;
|
||||
use Grav\Plugin\Admin\SafeUpgradeManager;
|
||||
use Grav\Plugin\Admin\Twig\AdminTwigExtension;
|
||||
use Grav\Plugin\Admin\WhiteLabel;
|
||||
use Grav\Plugin\Form\Form;
|
||||
@@ -383,6 +384,29 @@ class AdminPlugin extends Plugin
|
||||
'reports' => [['admin.super'], 'PLUGIN_ADMIN.REPORTS'],
|
||||
'direct-install' => [['admin.super'], 'PLUGIN_ADMIN.DIRECT_INSTALL'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$manifestFiles = glob(GRAV_ROOT . '/user/data/upgrades/*.json') ?: [];
|
||||
|
||||
if (!$manifestFiles) {
|
||||
$manager = new SafeUpgradeManager(Grav::instance());
|
||||
$manifestFiles = $manager->hasSnapshots() ? [true] : [];
|
||||
}
|
||||
|
||||
$tools = $event['tools'];
|
||||
Grav::instance()['log']->debug('[Admin] Tools before restore grav: ' . implode(',', array_keys($tools)));
|
||||
|
||||
if ($manifestFiles) {
|
||||
$tools['restore-grav'] = [['admin.super'], 'PLUGIN_ADMIN.RESTORE_GRAV'];
|
||||
Grav::instance()['log']->debug('[Admin] Restore Grav tool enabled');
|
||||
}
|
||||
|
||||
$event['tools'] = $tools;
|
||||
Grav::instance()['log']->debug('[Admin] Tools after register: ' . implode(',', array_keys($tools)));
|
||||
} catch (\Throwable $e) {
|
||||
// ignore availability errors, snapshots tool will simply stay hidden
|
||||
Grav::instance()['log']->warning('[Admin] Restore Grav detection failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,6 +23,7 @@ pages:
|
||||
show_modular: true
|
||||
session:
|
||||
timeout: 1800
|
||||
keep_alive: true
|
||||
edit_mode: normal
|
||||
frontend_preview_target: inline
|
||||
show_github_msg: true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Admin Panel
|
||||
slug: admin
|
||||
type: plugin
|
||||
version: 1.10.49.1
|
||||
version: 1.10.50
|
||||
description: Adds an advanced administration panel to manage your site
|
||||
icon: empire
|
||||
author:
|
||||
@@ -224,6 +224,18 @@ form:
|
||||
type: number
|
||||
min: 1
|
||||
|
||||
session.keep_alive:
|
||||
type: toggle
|
||||
label: Keep Alive Ping
|
||||
help: "Periodically pings to keep your admin session alive. Turn OFF to allow the session to expire while idle (useful for testing timeouts)."
|
||||
highlight: 1
|
||||
default: 1
|
||||
options:
|
||||
1: PLUGIN_ADMIN.ENABLED
|
||||
0: PLUGIN_ADMIN.DISABLED
|
||||
validate:
|
||||
type: bool
|
||||
|
||||
hide_page_types:
|
||||
type: select
|
||||
size: large
|
||||
|
||||
@@ -341,6 +341,31 @@ class Admin
|
||||
return array_unique($perms);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{id:string, source_version:?string, target_version:?string, created_at:int, created_at_iso:?string, backup_path:?string, package_path:?string}>
|
||||
*/
|
||||
public function safeUpgradeSnapshots(): array
|
||||
{
|
||||
try {
|
||||
$manager = new SafeUpgradeManager();
|
||||
|
||||
return $manager->listSnapshots();
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public function safeUpgradeHasSnapshots(): bool
|
||||
{
|
||||
try {
|
||||
$manager = new SafeUpgradeManager();
|
||||
|
||||
return $manager->hasSnapshots();
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the languages available in the site
|
||||
*
|
||||
|
||||
@@ -96,6 +96,8 @@ class AdminBaseController
|
||||
|
||||
// Make sure that user is logged into admin.
|
||||
if (!$this->admin->authorize()) {
|
||||
$this->respondUnauthorizedIfAjax();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -236,6 +238,31 @@ class AdminBaseController
|
||||
$this->close($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a JSON 401 response when an unauthenticated request was clearly triggered via AJAX.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function respondUnauthorizedIfAjax(): void
|
||||
{
|
||||
$uri = $this->grav['uri'] ?? null;
|
||||
$extension = $uri ? $uri->extension() : null;
|
||||
$accept = $_SERVER['HTTP_ACCEPT'] ?? '';
|
||||
$requestedWith = $_SERVER['HTTP_X_REQUESTED_WITH'] ?? '';
|
||||
|
||||
$acceptsJson = is_string($accept) && (stripos($accept, 'application/json') !== false || stripos($accept, 'text/json') !== false);
|
||||
$isAjax = ($extension === 'json') || $acceptsJson || (is_string($requestedWith) && strtolower($requestedWith) === 'xmlhttprequest');
|
||||
|
||||
if (!$isAjax) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sendJsonResponse([
|
||||
'status' => 'unauthenticated',
|
||||
'message' => Admin::translate('PLUGIN_ADMIN.SESSION_EXPIRED_DESC')
|
||||
], 401);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ResponseInterface $response
|
||||
* @return never-return
|
||||
|
||||
@@ -54,6 +54,9 @@ use Twig\Loader\FilesystemLoader;
|
||||
*/
|
||||
class AdminController extends AdminBaseController
|
||||
{
|
||||
/** @var SafeUpgradeManager|null */
|
||||
protected $safeUpgradeManager;
|
||||
|
||||
/**
|
||||
* @param Grav|null $grav
|
||||
* @param string|null $view
|
||||
@@ -750,6 +753,18 @@ class AdminController extends AdminBaseController
|
||||
|
||||
// INSTALL & UPGRADE
|
||||
|
||||
/**
|
||||
* @return SafeUpgradeManager
|
||||
*/
|
||||
protected function getSafeUpgradeManager()
|
||||
{
|
||||
if (null === $this->safeUpgradeManager) {
|
||||
$this->safeUpgradeManager = new SafeUpgradeManager();
|
||||
}
|
||||
|
||||
return $this->safeUpgradeManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles updating Grav
|
||||
*
|
||||
@@ -791,6 +806,273 @@ class AdminController extends AdminBaseController
|
||||
$this->sendJsonResponse($json_response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe upgrade preflight endpoint.
|
||||
*
|
||||
* Route: GET /update.json/task:safeUpgradePreflight (AJAX call)
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function taskSafeUpgradePreflight()
|
||||
{
|
||||
if (!$this->authorizeTask('install grav', ['admin.super'])) {
|
||||
$this->admin->json_response = [
|
||||
'status' => 'error',
|
||||
'message' => $this->admin::translate('PLUGIN_ADMIN.INSUFFICIENT_PERMISSIONS_FOR_TASK')
|
||||
];
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$post = $this->getPost($_POST ?? []);
|
||||
$force = !empty($post['force']);
|
||||
|
||||
$result = $this->getSafeUpgradeManager()->preflight($force);
|
||||
|
||||
$status = $result['status'] ?? 'ready';
|
||||
$response = [
|
||||
'status' => $status === 'error' ? 'error' : 'success',
|
||||
'data' => $result,
|
||||
];
|
||||
|
||||
if (!empty($result['message'])) {
|
||||
$response['message'] = $result['message'];
|
||||
}
|
||||
|
||||
$this->sendJsonResponse($response);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start safe upgrade process.
|
||||
*
|
||||
* Route: POST /update.json/task:safeUpgradeStart (AJAX call)
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function taskSafeUpgradeStart()
|
||||
{
|
||||
if (!$this->authorizeTask('install grav', ['admin.super'])) {
|
||||
$this->admin->json_response = [
|
||||
'status' => 'error',
|
||||
'message' => $this->admin::translate('PLUGIN_ADMIN.INSUFFICIENT_PERMISSIONS_FOR_TASK')
|
||||
];
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$post = $this->getPost($_POST ?? []);
|
||||
$options = [
|
||||
'force' => !empty($post['force']),
|
||||
'timeout' => isset($post['timeout']) ? (int)$post['timeout'] : 30,
|
||||
'overwrite' => !empty($post['overwrite']),
|
||||
'decisions' => isset($post['decisions']) && is_array($post['decisions']) ? $post['decisions'] : [],
|
||||
];
|
||||
|
||||
$manager = $this->getSafeUpgradeManager();
|
||||
$result = $manager->queue($options);
|
||||
$status = $result['status'] ?? 'error';
|
||||
|
||||
if ($status === 'error') {
|
||||
$manager->clearJobContext();
|
||||
$result = $manager->run($options);
|
||||
$status = $result['status'] ?? 'error';
|
||||
$result['fallback'] = true;
|
||||
}
|
||||
|
||||
$response = [
|
||||
'status' => $status === 'error' ? 'error' : 'success',
|
||||
'data' => $result,
|
||||
];
|
||||
|
||||
if (!empty($result['message'])) {
|
||||
$response['message'] = $result['message'];
|
||||
}
|
||||
|
||||
$this->sendJsonResponse($response);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll safe upgrade progress.
|
||||
*
|
||||
* Route: GET /update.json/task:safeUpgradeStatus (AJAX call)
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function taskSafeUpgradeStatus()
|
||||
{
|
||||
if (!$this->authorizeTask('install grav', ['admin.super'])) {
|
||||
$this->admin->json_response = [
|
||||
'status' => 'error',
|
||||
'message' => $this->admin::translate('PLUGIN_ADMIN.INSUFFICIENT_PERMISSIONS_FOR_TASK')
|
||||
];
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$manager = $this->getSafeUpgradeManager();
|
||||
$jobId = isset($_GET['job']) ? (string)$_GET['job'] : '';
|
||||
|
||||
if ($jobId !== '') {
|
||||
$data = $manager->getJobStatus($jobId);
|
||||
} else {
|
||||
$data = [
|
||||
'job' => null,
|
||||
'progress' => $manager->getProgress(),
|
||||
];
|
||||
}
|
||||
|
||||
$this->sendJsonResponse([
|
||||
'status' => 'success',
|
||||
'data' => $data,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a safe-upgrade snapshot via Tools.
|
||||
*
|
||||
* Route: POST /tools/restore-grav?task:safeUpgradeRestore
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function taskSafeUpgradeRestore()
|
||||
{
|
||||
if (!$this->authorizeTask('install grav', ['admin.super'])) {
|
||||
$this->sendJsonResponse([
|
||||
'status' => 'error',
|
||||
'message' => $this->admin::translate('PLUGIN_ADMIN.INSUFFICIENT_PERMISSIONS_FOR_TASK')
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$post = $this->getPost($_POST ?? []);
|
||||
$snapshotId = isset($post['snapshot']) ? (string)$post['snapshot'] : '';
|
||||
|
||||
if ($snapshotId === '') {
|
||||
$this->sendJsonResponse([
|
||||
'status' => 'error',
|
||||
'message' => $this->admin::translate('PLUGIN_ADMIN.RESTORE_GRAV_INVALID')
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$manager = $this->getSafeUpgradeManager();
|
||||
$result = $manager->queueRestore($snapshotId);
|
||||
$status = $result['status'] ?? 'error';
|
||||
|
||||
$response = [
|
||||
'status' => $status === 'error' ? 'error' : 'success',
|
||||
'data' => $result,
|
||||
];
|
||||
|
||||
if (!empty($result['message'])) {
|
||||
$response['message'] = $result['message'];
|
||||
}
|
||||
|
||||
$this->sendJsonResponse($response);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a manual safe-upgrade snapshot via Tools.
|
||||
*
|
||||
* Route: POST /tools/restore-grav?task:safeUpgradeSnapshot
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function taskSafeUpgradeSnapshot()
|
||||
{
|
||||
if (!$this->authorizeTask('install grav', ['admin.super'])) {
|
||||
$this->sendJsonResponse([
|
||||
'status' => 'error',
|
||||
'message' => $this->admin::translate('PLUGIN_ADMIN.INSUFFICIENT_PERMISSIONS_FOR_TASK')
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$post = $this->getPost($_POST ?? []);
|
||||
$label = isset($post['label']) ? (string)$post['label'] : null;
|
||||
|
||||
$manager = $this->getSafeUpgradeManager();
|
||||
$result = $manager->queueSnapshot($label);
|
||||
$status = $result['status'] ?? 'error';
|
||||
|
||||
$response = [
|
||||
'status' => $status === 'error' ? 'error' : 'success',
|
||||
'data' => $result,
|
||||
];
|
||||
|
||||
if (!empty($result['message'])) {
|
||||
$response['message'] = $result['message'];
|
||||
}
|
||||
|
||||
$this->sendJsonResponse($response);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete one or more safe-upgrade snapshots via Tools.
|
||||
*
|
||||
* Route: POST /tools/restore-grav?task:safeUpgradeDelete
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function taskSafeUpgradeDelete()
|
||||
{
|
||||
if (!$this->authorizeTask('install grav', ['admin.super'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$post = $this->getPost($_POST ?? []);
|
||||
$snapshots = $post['snapshots'] ?? [];
|
||||
if (is_string($snapshots)) {
|
||||
$snapshots = [$snapshots];
|
||||
}
|
||||
|
||||
if (empty($snapshots)) {
|
||||
$this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.RESTORE_GRAV_INVALID'), 'error');
|
||||
$this->setRedirect('/tools/restore-grav');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$manager = $this->getSafeUpgradeManager();
|
||||
$results = $manager->deleteSnapshots($snapshots);
|
||||
|
||||
$success = array_filter($results, static function ($item) {
|
||||
return ($item['status'] ?? 'error') === 'success';
|
||||
});
|
||||
$failed = array_filter($results, static function ($item) {
|
||||
return ($item['status'] ?? 'error') !== 'success';
|
||||
});
|
||||
|
||||
if ($success) {
|
||||
$this->admin->setMessage(
|
||||
sprintf($this->admin::translate('PLUGIN_ADMIN.RESTORE_GRAV_DELETE_SUCCESS'), count($success)),
|
||||
'info'
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($failed as $entry) {
|
||||
$message = $entry['message'] ?? $this->admin::translate('PLUGIN_ADMIN.RESTORE_GRAV_DELETE_FAILED');
|
||||
$this->admin->setMessage($message, 'error');
|
||||
}
|
||||
|
||||
$this->setRedirect('/tools/restore-grav');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles uninstalling plugins and themes
|
||||
*
|
||||
@@ -2144,6 +2426,7 @@ class AdminController extends AdminBaseController
|
||||
* @var string $name
|
||||
* @var Medium|ImageMedium $medium
|
||||
*/
|
||||
$this->grav['log']->debug('[AI Pro][listmedia] route=' . $this->route . ' path=' . ($media->getPath() ?: 'n/a') . ' count=' . count($media->all()));
|
||||
foreach ($media->all() as $name => $medium) {
|
||||
|
||||
$metadata = [];
|
||||
|
||||
@@ -231,6 +231,34 @@ class LoginController extends AdminController
|
||||
return $this->createRedirectResponse('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a fresh login nonce and keep anonymous session alive while on the login screen.
|
||||
*
|
||||
* Route: GET /login.json/task:nonce
|
||||
*
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function taskNonce(): ResponseInterface
|
||||
{
|
||||
// Touch the anonymous session to prevent immediate expiry on the login page.
|
||||
$session = $this->getSession();
|
||||
if (!$session->isStarted()) {
|
||||
$session->start();
|
||||
}
|
||||
$session->__set('admin_login_keepalive', time());
|
||||
|
||||
// Generate a fresh nonce for the login form.
|
||||
$nonce = Admin::getNonce($this->nonce_action);
|
||||
|
||||
return $this->createJsonResponse([
|
||||
'status' => 'success',
|
||||
'message' => null,
|
||||
'nonce_name' => $this->nonce_name,
|
||||
'nonce_action' => $this->nonce_action,
|
||||
'nonce' => $nonce
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle 2FA verification.
|
||||
*
|
||||
|
||||
@@ -47,8 +47,9 @@ class LoginRouter
|
||||
];
|
||||
}
|
||||
|
||||
$httpMethod = $request->getMethod();
|
||||
$template = $this->taskTemplates[$task] ?? $adminInfo['view'];
|
||||
$httpMethod = $request->getMethod() ?? '';
|
||||
$template = $this->taskTemplates[$task ?? ''] ?? $adminInfo['view'];
|
||||
|
||||
$params = [];
|
||||
|
||||
switch ($template) {
|
||||
|
||||
1861
classes/plugin/SafeUpgradeManager.php
Normal file
1861
classes/plugin/SafeUpgradeManager.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -178,6 +178,11 @@ PLUGIN_ADMIN:
|
||||
PAGES_FILTERED: "Pages filtered"
|
||||
NO_PAGE_FOUND: "No Page found"
|
||||
INVALID_PARAMETERS: "Invalid Parameters"
|
||||
|
||||
# Session expiration modal (Admin keep-alive/offline handling)
|
||||
SESSION_EXPIRED: "Session Expired"
|
||||
SESSION_EXPIRED_DESC: "Your admin login session has expired. Click OK to log in again."
|
||||
OK: "OK"
|
||||
NO_FILES_SENT: "No files sent"
|
||||
EXCEEDED_FILESIZE_LIMIT: "Exceeded PHP configuration upload_max_filesize"
|
||||
EXCEEDED_POSTMAX_LIMIT: "Exceeded PHP configuration post_max_size"
|
||||
@@ -491,6 +496,58 @@ PLUGIN_ADMIN:
|
||||
UPDATE_GRAV_NOW: "Update Grav Now"
|
||||
GRAV_SYMBOLICALLY_LINKED: "Grav is symbolically linked. Upgrade won't be available"
|
||||
UPDATING_PLEASE_WAIT: "Updating... please wait, downloading"
|
||||
SAFE_UPGRADE_TITLE: "Safe Upgrade"
|
||||
SAFE_UPGRADE_DESC: "Run Grav core upgrades with staging, validation, and rollback support."
|
||||
SAFE_UPGRADE_CHECKING: "Running preflight checks..."
|
||||
SAFE_UPGRADE_GENERIC_ERROR: "Safe upgrade could not complete. See Grav logs for details."
|
||||
SAFE_UPGRADE_RECHECK: "Re-run Checks"
|
||||
SAFE_UPGRADE_RECHECKING: "Re-running Checks..."
|
||||
SAFE_UPGRADE_SUMMARY_CURRENT: "Current Grav version: <strong>v%s</strong>"
|
||||
SAFE_UPGRADE_SUMMARY_REMOTE: "Available Grav version: <strong>v%s</strong>"
|
||||
SAFE_UPGRADE_RELEASED_ON: "Released on %s"
|
||||
SAFE_UPGRADE_PACKAGE_SIZE: "Package size: %s"
|
||||
SAFE_UPGRADE_UNKNOWN_SIZE: "unknown"
|
||||
SAFE_UPGRADE_WARNINGS: "Warnings"
|
||||
SAFE_UPGRADE_WARNINGS_HINT: "These items may require attention before continuing."
|
||||
SAFE_UPGRADE_WARNINGS_PSR_ITEM: "Potential psr/log conflict:"
|
||||
SAFE_UPGRADE_WARNINGS_MONOLOG_ITEM: "Potential Monolog conflict:"
|
||||
SAFE_UPGRADE_WARNINGS_MONOLOG_UNKNOWN: "Review the plugin for potential API changes."
|
||||
SAFE_UPGRADE_PENDING_UPDATES: "Pending plugin or theme updates"
|
||||
SAFE_UPGRADE_PENDING_INTRO: "Because this is a major Grav upgrade, update these extensions first to ensure maximum compatibility."
|
||||
SAFE_UPGRADE_PENDING_MINOR_DESC: "These updates are optional for this release; apply them at your convenience."
|
||||
SAFE_UPGRADE_PENDING_HINT: "Because this is a major upgrade, update all plugins and themes before continuing to ensure maximum compatibility."
|
||||
SAFE_UPGRADE_UNKNOWN_VERSION: "unknown"
|
||||
SAFE_UPGRADE_REQUIREMENTS_FAIL: "PHP %s or newer is required before continuing."
|
||||
SAFE_UPGRADE_DISABLED: "Safe upgrade is disabled. Enable it in Configuration ▶ System ▶ Updates."
|
||||
SAFE_UPGRADE_STAGING_ERROR: "Safe upgrade staging directory is not writable."
|
||||
SAFE_UPGRADE_NOT_AVAILABLE: "No Grav update is available."
|
||||
SAFE_UPGRADE_CONFLICTS_PSR: "Potential psr/log compatibility issues"
|
||||
SAFE_UPGRADE_CONFLICTS_REQUIRES: "Requires psr/log %s"
|
||||
SAFE_UPGRADE_CONFLICTS_MONOLOG: "Potential Monolog API compatibility issues"
|
||||
SAFE_UPGRADE_CONFLICTS_HINT: "Choose how to handle conflicts before starting the upgrade."
|
||||
SAFE_UPGRADE_DECISION_PROMPT: "When conflicts are detected:"
|
||||
SAFE_UPGRADE_DECISION_DISABLE: "Disable conflicting plugins"
|
||||
SAFE_UPGRADE_DECISION_DISABLE_DESC: "Temporarily disable conflicting plugins during the upgrade."
|
||||
SAFE_UPGRADE_DECISION_CONTINUE: "Continue with plugins enabled"
|
||||
SAFE_UPGRADE_DECISION_CONTINUE_DESC: "Proceed with plugins enabled. This may require manual fixes."
|
||||
SAFE_UPGRADE_BLOCKERS_TITLE: "Action required before continuing"
|
||||
SAFE_UPGRADE_BLOCKERS_DESC: "Resolve the following items to enable the upgrade."
|
||||
SAFE_UPGRADE_START: "Start Safe Upgrade"
|
||||
SAFE_UPGRADE_FINISH: "Finish"
|
||||
SAFE_UPGRADE_STAGE_QUEUED: "Waiting for worker"
|
||||
SAFE_UPGRADE_STAGE_INITIALIZING: "Preparing upgrade"
|
||||
SAFE_UPGRADE_STAGE_DOWNLOADING: "Downloading update"
|
||||
SAFE_UPGRADE_STAGE_SNAPSHOT: "Creating backup snapshot"
|
||||
SAFE_UPGRADE_STAGE_INSTALLING: "Installing update"
|
||||
SAFE_UPGRADE_STAGE_ROLLBACK: "Restoring snapshot"
|
||||
SAFE_UPGRADE_STAGE_FINALIZING: "Finalizing changes"
|
||||
SAFE_UPGRADE_STAGE_COMPLETE: "Upgrade complete"
|
||||
SAFE_UPGRADE_STAGE_ERROR: "Upgrade encountered an error"
|
||||
SAFE_UPGRADE_RESULT_SUCCESS: "Grav Upgrade to v%s Successful!"
|
||||
SAFE_UPGRADE_RESULT_MANIFEST: "Snapshot reference: <code>%s</code>"
|
||||
SAFE_UPGRADE_RESULT_HINT: "Restore snapshots from Tools → Restore Grav."
|
||||
SAFE_UPGRADE_RESULT_NOOP: "Grav is already up to date."
|
||||
SAFE_UPGRADE_RESULT_FAILURE: "Safe upgrade failed"
|
||||
OF_THIS: "of this"
|
||||
OF_YOUR: "of your"
|
||||
HAVE_AN_UPDATE_AVAILABLE: "have an update available"
|
||||
@@ -787,6 +844,30 @@ PLUGIN_ADMIN:
|
||||
TOOLS_DIRECT_INSTALL_URL_TITLE: "Install Package via Remote URL Reference"
|
||||
TOOLS_DIRECT_INSTALL_URL_DESC: "Alternatively, you can also reference a full URL to the package ZIP file and install it via this remote URL."
|
||||
TOOLS_DIRECT_INSTALL_UPLOAD_BUTTON: "Upload and install"
|
||||
RESTORE_GRAV: "Restore Grav"
|
||||
RESTORE_GRAV_DESC: "Select a snapshot created by Safe Upgrade to restore your site or remove snapshots you no longer need."
|
||||
RESTORE_GRAV_TABLE_SNAPSHOT: "Snapshot"
|
||||
RESTORE_GRAV_TABLE_VERSION: "Version"
|
||||
RESTORE_GRAV_TABLE_CREATED: "Created"
|
||||
RESTORE_GRAV_TABLE_LOCATION: "Snapshot path"
|
||||
RESTORE_GRAV_TABLE_ACTIONS: "Actions"
|
||||
RESTORE_GRAV_RESTORE_BUTTON: "Restore"
|
||||
RESTORE_GRAV_DELETE_SELECTED: "Delete Selected"
|
||||
RESTORE_GRAV_NONE: "No safe upgrade snapshots are currently available."
|
||||
RESTORE_GRAV_INVALID: "Select at least one snapshot before continuing."
|
||||
RESTORE_GRAV_SUCCESS: "Snapshot %s restored (Grav %s)."
|
||||
RESTORE_GRAV_SUCCESS_MESSAGE: "Snapshot %1$s restored (Grav %2$s)."
|
||||
RESTORE_GRAV_SUCCESS_SIMPLE: "Snapshot %s restored."
|
||||
RESTORE_GRAV_RUNNING: "Restoring snapshot %s..."
|
||||
RESTORE_GRAV_FAILED: "Unable to restore the selected snapshot."
|
||||
RESTORE_GRAV_CREATE_SNAPSHOT: "Create Snapshot"
|
||||
RESTORE_GRAV_SNAPSHOT_PROMPT: "Enter an optional snapshot label"
|
||||
RESTORE_GRAV_SNAPSHOT_RUNNING: "Creating snapshot %s..."
|
||||
RESTORE_GRAV_SNAPSHOT_SUCCESS: "Snapshot %s created."
|
||||
RESTORE_GRAV_SNAPSHOT_FAILED: "Snapshot creation failed."
|
||||
RESTORE_GRAV_SNAPSHOT_FALLBACK: "Snapshot creation may have completed. Reloading..."
|
||||
RESTORE_GRAV_DELETE_SUCCESS: "%d snapshot(s) deleted."
|
||||
RESTORE_GRAV_DELETE_FAILED: "Failed to delete one or more snapshots."
|
||||
ROUTE_OVERRIDES: "Route Overrides"
|
||||
ROUTE_DEFAULT: "Default Route"
|
||||
ROUTE_CANONICAL: "Canonical Route"
|
||||
|
||||
179
safe-upgrade-status.php
Normal file
179
safe-upgrade-status.php
Normal file
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$scriptRoot = isset($_SERVER['SCRIPT_FILENAME']) ? dirname($_SERVER['SCRIPT_FILENAME']) : null;
|
||||
$root = \defined('GRAV_ROOT') ? GRAV_ROOT : ($scriptRoot ?: dirname(__DIR__, 3));
|
||||
$jobsDir = $root . '/user/data/upgrades/jobs';
|
||||
$fallbackProgress = $root . '/user/data/upgrades/safe-upgrade-progress.json';
|
||||
|
||||
if (!\defined('GRAV_ROOT')) {
|
||||
\define('GRAV_ROOT', $root);
|
||||
}
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||
header('Pragma: no-cache');
|
||||
|
||||
$jobId = isset($_GET['job']) ? (string)$_GET['job'] : '';
|
||||
|
||||
if ($jobId !== '' && !preg_match('/^job-[A-Za-z0-9\\-]+$/', $jobId)) {
|
||||
http_response_code(400);
|
||||
echo json_encode([
|
||||
'status' => 'error',
|
||||
'message' => 'Invalid job identifier.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$readJson = static function (string $path): ?array {
|
||||
if (!is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string)file_get_contents($path), true);
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
};
|
||||
|
||||
$progress = null;
|
||||
$manifest = null;
|
||||
$manifestPath = null;
|
||||
$progressPath = null;
|
||||
|
||||
$normalizeDir = static function (string $path): string {
|
||||
$normalized = str_replace('\\', '/', $path);
|
||||
|
||||
return rtrim($normalized, '/');
|
||||
};
|
||||
|
||||
$jobsDirNormalized = $normalizeDir(realpath($jobsDir) ?: $jobsDir);
|
||||
$userDataDirNormalized = $normalizeDir(realpath(dirname($jobsDir)) ?: dirname($jobsDir));
|
||||
$toRelative = static function (string $path): string {
|
||||
$normalized = str_replace('\\', '/', $path);
|
||||
$root = str_replace('\\', '/', GRAV_ROOT);
|
||||
|
||||
if (strpos($normalized, $root) === 0) {
|
||||
$relative = substr($normalized, strlen($root));
|
||||
|
||||
return ltrim($relative, '/');
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
};
|
||||
|
||||
$contextParam = $_GET['context'] ?? '';
|
||||
if ($contextParam !== '') {
|
||||
$decodedRaw = base64_decode(strtr($contextParam, ' ', '+'), true);
|
||||
if ($decodedRaw !== false) {
|
||||
$decoded = json_decode($decodedRaw, true);
|
||||
if (is_array($decoded)) {
|
||||
$validatePath = static function (string $candidate) use ($normalizeDir, $jobsDirNormalized, $userDataDirNormalized) {
|
||||
if ($candidate === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$candidate = str_replace('\\', '/', $candidate);
|
||||
|
||||
if ($candidate[0] !== '/' && !preg_match('/^[A-Za-z]:[\\\\\/]/', $candidate)) {
|
||||
$candidate = GRAV_ROOT . '/' . ltrim($candidate, '/');
|
||||
$candidate = str_replace('\\', '/', $candidate);
|
||||
}
|
||||
|
||||
$real = realpath($candidate);
|
||||
if ($real === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$real = $normalizeDir($real);
|
||||
if (strpos($real, $jobsDirNormalized) !== 0 && strpos($real, $userDataDirNormalized) !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $real;
|
||||
};
|
||||
|
||||
if (!empty($decoded['manifest'])) {
|
||||
$candidate = $validatePath((string)$decoded['manifest']);
|
||||
if ($candidate) {
|
||||
$manifestPath = $candidate;
|
||||
if (is_file($candidate)) {
|
||||
$manifest = $readJson($candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($decoded['progress'])) {
|
||||
$candidate = $validatePath((string)$decoded['progress']);
|
||||
if ($candidate) {
|
||||
$progressPath = $candidate;
|
||||
if (is_file($candidate)) {
|
||||
$progress = $readJson($candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($jobId !== '') {
|
||||
$jobPath = $jobsDir . '/' . $jobId;
|
||||
$progressPath = $progressPath ?: ($jobPath . '/progress.json');
|
||||
$manifestPath = $manifestPath ?: ($jobPath . '/manifest.json');
|
||||
if (is_file($progressPath)) {
|
||||
$progress = $readJson($progressPath);
|
||||
}
|
||||
if (is_file($manifestPath)) {
|
||||
$manifest = $readJson($manifestPath);
|
||||
}
|
||||
|
||||
if (!$progress && !$manifest && !is_dir($jobPath)) {
|
||||
$progress = $readJson($fallbackProgress) ?: [
|
||||
'stage' => 'idle',
|
||||
'message' => '',
|
||||
'percent' => null,
|
||||
'timestamp' => time(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($progress === null) {
|
||||
if ($progressPath && is_file($progressPath)) {
|
||||
$progress = $readJson($progressPath);
|
||||
}
|
||||
|
||||
if ($progress === null) {
|
||||
$progress = $readJson($fallbackProgress) ?: [
|
||||
'stage' => 'idle',
|
||||
'message' => '',
|
||||
'percent' => null,
|
||||
'timestamp' => time(),
|
||||
];
|
||||
$progressPath = $fallbackProgress;
|
||||
}
|
||||
}
|
||||
|
||||
if ($jobId !== '' && is_array($progress) && !isset($progress['job_id'])) {
|
||||
$progress['job_id'] = $jobId;
|
||||
}
|
||||
|
||||
$contextPayload = [];
|
||||
if ($manifestPath) {
|
||||
$contextPayload['manifest'] = $toRelative($manifestPath);
|
||||
}
|
||||
if ($progressPath) {
|
||||
$contextPayload['progress'] = $toRelative($progressPath);
|
||||
}
|
||||
|
||||
$contextToken = $contextPayload ? base64_encode(json_encode($contextPayload)) : null;
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'job' => $manifest ?: null,
|
||||
'progress' => $progress,
|
||||
'context' => $contextToken,
|
||||
],
|
||||
]);
|
||||
|
||||
exit;
|
||||
@@ -231,11 +231,15 @@ export default class CollectionsField {
|
||||
// special case to preserve array field index keys
|
||||
if (prop === 'name' && element.data('gravArrayType')) {
|
||||
const match_index = element.attr(prop).match(/\[[0-9]{1,}\]$/);
|
||||
const pattern = element[0].closest('[data-grav-array-name]').dataset.gravArrayName;
|
||||
if (match_index && pattern) {
|
||||
const array_container = element[0].closest('[data-grav-array-name]');
|
||||
|
||||
if (match_index) {
|
||||
array_index = match_index[0];
|
||||
element.attr(prop, `${pattern}${match_index[0]}`);
|
||||
return;
|
||||
element.attr(prop, element.attr(prop).slice(0, array_index.length * -1));
|
||||
}
|
||||
|
||||
if (array_container && array_container.dataset && array_container.dataset.gravArrayName) {
|
||||
element.attr(prop, array_container.dataset.gravArrayName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,14 +259,8 @@ export default class CollectionsField {
|
||||
|
||||
let matchedKey = currentKey;
|
||||
let replaced = element.attr(prop).replace(regexps[0], (/* str, p1, offset */) => {
|
||||
let extras = '';
|
||||
if (array_index) {
|
||||
extras = array_index;
|
||||
console.log(indexes, extras);
|
||||
}
|
||||
|
||||
matchedKey = indexes.shift() || matchedKey;
|
||||
return `[${matchedKey}]${extras}`;
|
||||
return `[${matchedKey}]`;
|
||||
});
|
||||
|
||||
replaced = replaced.replace(regexps[1], (/* str, p1, offset */) => {
|
||||
@@ -270,7 +268,7 @@ export default class CollectionsField {
|
||||
return `.${matchedKey}.`;
|
||||
});
|
||||
|
||||
element.attr(prop, replaced);
|
||||
element.attr(prop, array_index ? `${replaced}${array_index}` : replaced);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'simplebar/dist/simplebar.min.js';
|
||||
import { UriToMarkdown } from './forms/fields/files.js';
|
||||
import GPM, { Instance as gpm } from './utils/gpm';
|
||||
import KeepAlive from './utils/keepalive';
|
||||
import { config as GravConfig } from 'grav-config';
|
||||
import Updates, { Instance as updates, Notifications, Feed } from './updates';
|
||||
import Dashboard from './dashboard';
|
||||
import Pages from './pages';
|
||||
@@ -34,9 +35,20 @@ import './utils/changelog';
|
||||
|
||||
// Main Sidebar
|
||||
import Sidebar, { Instance as sidebar } from './utils/sidebar';
|
||||
import { bindGlobalAjaxTrap, installNavigationGuard } from './utils/session-expired';
|
||||
|
||||
// starts the keep alive, auto runs every X seconds
|
||||
KeepAlive.start();
|
||||
// starts the keep alive (if enabled), but never on auth views like login/forgot/reset/register
|
||||
const AUTH_VIEWS = ['login', 'forgot', 'reset', 'register'];
|
||||
const isAuthView = AUTH_VIEWS.includes(String(GravConfig.route || ''));
|
||||
if (!isAuthView && String(GravConfig.keep_alive_enabled) !== '0') {
|
||||
KeepAlive.start();
|
||||
}
|
||||
|
||||
// catch legacy jQuery XHR 401/403 globally
|
||||
bindGlobalAjaxTrap();
|
||||
|
||||
// intercept admin nav clicks to show modal before redirect on timeout
|
||||
installNavigationGuard();
|
||||
|
||||
// global event to catch sidebar_state changes
|
||||
$(global).on('sidebar_state._grav', () => {
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
import './logs';
|
||||
import './restore';
|
||||
|
||||
564
themes/grav/app/tools/restore.js
Normal file
564
themes/grav/app/tools/restore.js
Normal file
@@ -0,0 +1,564 @@
|
||||
import $ from 'jquery';
|
||||
import { config, translations } from 'grav-config';
|
||||
import request from '../utils/request';
|
||||
import toastr from '../utils/toastr';
|
||||
|
||||
const paramSep = config.param_sep;
|
||||
const task = `task${paramSep}`;
|
||||
const nonce = `admin-nonce${paramSep}${config.admin_nonce}`;
|
||||
const base = `${config.base_url_relative}/update.json`;
|
||||
|
||||
const urls = {
|
||||
restore: `${base}/${task}safeUpgradeRestore/${nonce}`,
|
||||
snapshot: `${base}/${task}safeUpgradeSnapshot/${nonce}`,
|
||||
status: `${base}/${task}safeUpgradeStatus/${nonce}`,
|
||||
};
|
||||
|
||||
const NICETIME_PERIODS_SHORT = [
|
||||
'NICETIME.SEC',
|
||||
'NICETIME.MIN',
|
||||
'NICETIME.HR',
|
||||
'NICETIME.DAY',
|
||||
'NICETIME.WK',
|
||||
'NICETIME.MO',
|
||||
'NICETIME.YR',
|
||||
'NICETIME.DEC'
|
||||
];
|
||||
|
||||
const NICETIME_PERIODS_LONG = [
|
||||
'NICETIME.SECOND',
|
||||
'NICETIME.MINUTE',
|
||||
'NICETIME.HOUR',
|
||||
'NICETIME.DAY',
|
||||
'NICETIME.WEEK',
|
||||
'NICETIME.MONTH',
|
||||
'NICETIME.YEAR',
|
||||
'NICETIME.DECADE'
|
||||
];
|
||||
|
||||
const NICETIME_LENGTHS = [60, 60, 24, 7, 4.35, 12, 10];
|
||||
const FAST_UPDATE_THRESHOLD_SECONDS = 60;
|
||||
const FAST_REFRESH_INTERVAL_MS = 1000;
|
||||
const SLOW_REFRESH_INTERVAL_MS = 60000;
|
||||
const NICETIME_TRANSLATION_ROOTS = ['GRAV_CORE', 'GRAV'];
|
||||
|
||||
const NICETIME_BASE_FALLBACKS = {
|
||||
SECOND: 'second',
|
||||
MINUTE: 'minute',
|
||||
HOUR: 'hour',
|
||||
DAY: 'day',
|
||||
WEEK: 'week',
|
||||
MONTH: 'month',
|
||||
YEAR: 'year',
|
||||
DECADE: 'decade',
|
||||
SEC: 'sec',
|
||||
MIN: 'min',
|
||||
HR: 'hr',
|
||||
WK: 'wk',
|
||||
MO: 'mo',
|
||||
YR: 'yr',
|
||||
DEC: 'dec'
|
||||
};
|
||||
|
||||
const NICETIME_PLURAL_FALLBACKS = {
|
||||
SECOND: 'seconds',
|
||||
MINUTE: 'minutes',
|
||||
HOUR: 'hours',
|
||||
DAY: 'days',
|
||||
WEEK: 'weeks',
|
||||
MONTH: 'months',
|
||||
YEAR: 'years',
|
||||
DECADE: 'decades',
|
||||
SEC: 'secs',
|
||||
MIN: 'mins',
|
||||
HR: 'hrs',
|
||||
WK: 'wks',
|
||||
MO: 'mos',
|
||||
YR: 'yrs',
|
||||
DEC: 'decs'
|
||||
};
|
||||
|
||||
const getTranslationKey = (key) => {
|
||||
for (const root of NICETIME_TRANSLATION_ROOTS) {
|
||||
const catalog = translations[root];
|
||||
if (catalog && Object.prototype.hasOwnProperty.call(catalog, key)) {
|
||||
const value = catalog[key];
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const nicetimeHasKey = (key) => typeof getTranslationKey(key) === 'string';
|
||||
|
||||
const nicetimeTranslate = (key, fallback) => {
|
||||
const value = getTranslationKey(key);
|
||||
if (typeof value === 'string' && value.length) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const getFallbackForPeriodKey = (key) => {
|
||||
const normalized = key.replace(/^GRAV\./, '');
|
||||
const period = normalized.replace(/^NICETIME\./, '');
|
||||
const plural = /_PLURAL/.test(period);
|
||||
const baseKey = period.replace(/_PLURAL(_MORE_THAN_TWO)?$/, '');
|
||||
const base = NICETIME_BASE_FALLBACKS[baseKey] || baseKey.toLowerCase();
|
||||
|
||||
if (!plural) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const pluralKey = NICETIME_PLURAL_FALLBACKS[baseKey];
|
||||
if (pluralKey) {
|
||||
return pluralKey;
|
||||
}
|
||||
|
||||
if (base.endsWith('y')) {
|
||||
return `${base.slice(0, -1)}ies`;
|
||||
}
|
||||
|
||||
if (base.endsWith('s')) {
|
||||
return `${base}es`;
|
||||
}
|
||||
|
||||
return `${base}s`;
|
||||
};
|
||||
|
||||
const parseTimestampValue = (input) => {
|
||||
if (input instanceof Date) {
|
||||
return Math.floor(input.getTime() / 1000);
|
||||
}
|
||||
|
||||
if (typeof input === 'number' && Number.isFinite(input)) {
|
||||
return input > 1e12 ? Math.floor(input / 1000) : Math.floor(input);
|
||||
}
|
||||
|
||||
if (typeof input === 'string') {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numeric = Number(trimmed);
|
||||
if (!Number.isNaN(numeric) && trimmed === String(numeric)) {
|
||||
return numeric > 1e12 ? Math.floor(numeric / 1000) : Math.floor(numeric);
|
||||
}
|
||||
|
||||
const parsed = Date.parse(trimmed);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return Math.floor(parsed / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const computeNicetime = (input, { longStrings = false, showTense = false } = {}) => {
|
||||
if (input === null || input === undefined || input === '') {
|
||||
return nicetimeTranslate('NICETIME.NO_DATE_PROVIDED', 'No date provided');
|
||||
}
|
||||
|
||||
const unixDate = parseTimestampValue(input);
|
||||
if (unixDate === null) {
|
||||
return nicetimeTranslate('NICETIME.BAD_DATE', 'Bad date');
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const periods = (longStrings ? NICETIME_PERIODS_LONG : NICETIME_PERIODS_SHORT).slice();
|
||||
|
||||
let difference;
|
||||
let tense;
|
||||
|
||||
if (now > unixDate) {
|
||||
difference = now - unixDate;
|
||||
tense = nicetimeTranslate('NICETIME.AGO', 'ago');
|
||||
} else if (now === unixDate) {
|
||||
difference = 0;
|
||||
tense = nicetimeTranslate('NICETIME.JUST_NOW', 'just now');
|
||||
} else {
|
||||
difference = unixDate - now;
|
||||
tense = nicetimeTranslate('NICETIME.FROM_NOW', 'from now');
|
||||
}
|
||||
|
||||
if (now === unixDate) {
|
||||
return tense;
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
while (index < NICETIME_LENGTHS.length - 1 && difference >= NICETIME_LENGTHS[index]) {
|
||||
difference /= NICETIME_LENGTHS[index];
|
||||
index += 1;
|
||||
}
|
||||
|
||||
difference = Math.round(difference);
|
||||
let periodKey = periods[index];
|
||||
|
||||
if (difference !== 1) {
|
||||
periodKey += '_PLURAL';
|
||||
const moreThanTwoKey = `${periodKey}_MORE_THAN_TWO`;
|
||||
if (difference > 2 && nicetimeHasKey(moreThanTwoKey)) {
|
||||
periodKey = moreThanTwoKey;
|
||||
}
|
||||
}
|
||||
|
||||
const labelFallback = periodKey.split('.').pop().toLowerCase();
|
||||
const fallbackLabel = getFallbackForPeriodKey(periodKey) || labelFallback;
|
||||
const periodLabel = nicetimeTranslate(periodKey, fallbackLabel);
|
||||
const timeString = `${difference} ${periodLabel}`;
|
||||
|
||||
return showTense ? `${timeString} ${tense}` : timeString;
|
||||
};
|
||||
|
||||
const parseBoolAttribute = (element, attributeName, defaultValue = false) => {
|
||||
const rawValue = element.getAttribute(attributeName);
|
||||
if (rawValue === null) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const normalized = rawValue.trim().toLowerCase();
|
||||
if (normalized === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ['1', 'true', 'yes', 'on'].includes(normalized);
|
||||
};
|
||||
|
||||
const initialiseNicetimeUpdater = () => {
|
||||
const selector = '[data-nicetime-timestamp]';
|
||||
if (!document.querySelector(selector)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
let youngestAge = Infinity;
|
||||
|
||||
document.querySelectorAll(selector).forEach((element) => {
|
||||
const timestamp = element.getAttribute('data-nicetime-timestamp');
|
||||
const longStrings = parseBoolAttribute(element, 'data-nicetime-long', false);
|
||||
const showTense = parseBoolAttribute(element, 'data-nicetime-tense', false);
|
||||
const unixTimestamp = parseTimestampValue(timestamp);
|
||||
if (unixTimestamp !== null) {
|
||||
const age = Math.max(0, nowSeconds - unixTimestamp);
|
||||
if (age < youngestAge) {
|
||||
youngestAge = age;
|
||||
}
|
||||
}
|
||||
|
||||
const updated = computeNicetime(timestamp, { longStrings, showTense });
|
||||
|
||||
if (updated && element.textContent !== updated) {
|
||||
element.textContent = updated;
|
||||
}
|
||||
});
|
||||
|
||||
return youngestAge;
|
||||
};
|
||||
|
||||
let timerId = null;
|
||||
|
||||
const scheduleNext = (lastAge) => {
|
||||
const useFastInterval = Number.isFinite(lastAge) && lastAge < FAST_UPDATE_THRESHOLD_SECONDS;
|
||||
const delay = useFastInterval ? FAST_REFRESH_INTERVAL_MS : SLOW_REFRESH_INTERVAL_MS;
|
||||
|
||||
timerId = window.setTimeout(() => {
|
||||
const nextAge = update();
|
||||
scheduleNext(nextAge);
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const destroy = () => {
|
||||
if (timerId !== null) {
|
||||
window.clearTimeout(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const initialAge = update();
|
||||
scheduleNext(initialAge);
|
||||
|
||||
window.addEventListener('beforeunload', destroy, { once: true });
|
||||
|
||||
return { update, destroy };
|
||||
};
|
||||
|
||||
class RestoreManager {
|
||||
constructor() {
|
||||
this.job = null;
|
||||
this.pollTimer = null;
|
||||
this.pollFailures = 0;
|
||||
|
||||
$(document).on('click', '[data-restore-snapshot]', (event) => {
|
||||
event.preventDefault();
|
||||
const button = $(event.currentTarget);
|
||||
if (this.job) {
|
||||
return;
|
||||
}
|
||||
this.startRestore(button);
|
||||
});
|
||||
|
||||
$(document).on('click', '[data-create-snapshot]', (event) => {
|
||||
event.preventDefault();
|
||||
const button = $(event.currentTarget);
|
||||
if (this.job) {
|
||||
return;
|
||||
}
|
||||
this.startSnapshot(button);
|
||||
});
|
||||
}
|
||||
|
||||
startSnapshot(button) {
|
||||
let label = null;
|
||||
if (typeof window !== 'undefined' && window.prompt) {
|
||||
const promptMessage = translations.PLUGIN_ADMIN?.RESTORE_GRAV_SNAPSHOT_PROMPT || 'Enter an optional snapshot label';
|
||||
const input = window.prompt(promptMessage, '');
|
||||
if (input === null) {
|
||||
return;
|
||||
}
|
||||
label = input.trim();
|
||||
if (label === '') {
|
||||
label = null;
|
||||
}
|
||||
}
|
||||
|
||||
button.prop('disabled', true).addClass('is-loading');
|
||||
|
||||
const body = {};
|
||||
if (label) {
|
||||
body.label = label;
|
||||
}
|
||||
|
||||
request(urls.snapshot, { method: 'post', body }, (response) => {
|
||||
button.prop('disabled', false).removeClass('is-loading');
|
||||
|
||||
if (!response) {
|
||||
toastr.error(translations.PLUGIN_ADMIN?.RESTORE_GRAV_SNAPSHOT_FAILED || 'Snapshot creation failed.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 'error') {
|
||||
toastr.error(response.message || translations.PLUGIN_ADMIN?.RESTORE_GRAV_SNAPSHOT_FAILED || 'Snapshot creation failed.');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = response.data || {};
|
||||
const jobId = data.job_id || (data.job && data.job.id);
|
||||
if (!jobId) {
|
||||
const message = response.message || translations.PLUGIN_ADMIN?.RESTORE_GRAV_SNAPSHOT_FAILED || 'Snapshot creation failed.';
|
||||
toastr.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
this.job = {
|
||||
id: jobId,
|
||||
operation: 'snapshot',
|
||||
snapshot: null,
|
||||
label
|
||||
};
|
||||
this.pollFailures = 0;
|
||||
|
||||
const descriptor = label || jobId;
|
||||
const runningMessage = translations.PLUGIN_ADMIN?.RESTORE_GRAV_SNAPSHOT_RUNNING
|
||||
? translations.PLUGIN_ADMIN.RESTORE_GRAV_SNAPSHOT_RUNNING.replace('%s', descriptor)
|
||||
: 'Creating snapshot...';
|
||||
toastr.info(runningMessage);
|
||||
this.schedulePoll();
|
||||
});
|
||||
}
|
||||
|
||||
startRestore(button) {
|
||||
const snapshot = button.data('restore-snapshot');
|
||||
if (!snapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
button.prop('disabled', true).addClass('is-loading');
|
||||
|
||||
const body = { snapshot };
|
||||
request(urls.restore, { method: 'post', body }, (response) => {
|
||||
button.prop('disabled', false).removeClass('is-loading');
|
||||
|
||||
if (!response) {
|
||||
toastr.error(translations.PLUGIN_ADMIN?.RESTORE_GRAV_FAILED || 'Snapshot restore failed.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 'error') {
|
||||
toastr.error(response.message || translations.PLUGIN_ADMIN?.RESTORE_GRAV_FAILED || 'Snapshot restore failed.');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = response.data || {};
|
||||
const jobId = data.job_id || (data.job && data.job.id);
|
||||
if (!jobId) {
|
||||
const message = response.message || translations.PLUGIN_ADMIN?.RESTORE_GRAV_FAILED || 'Snapshot restore failed.';
|
||||
toastr.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
this.job = {
|
||||
id: jobId,
|
||||
snapshot,
|
||||
operation: 'restore',
|
||||
};
|
||||
this.pollFailures = 0;
|
||||
|
||||
const runningMessage = translations.PLUGIN_ADMIN?.RESTORE_GRAV_RUNNING
|
||||
? translations.PLUGIN_ADMIN.RESTORE_GRAV_RUNNING.replace('%s', snapshot)
|
||||
: `Restoring snapshot ${snapshot}...`;
|
||||
toastr.info(runningMessage);
|
||||
this.schedulePoll();
|
||||
});
|
||||
}
|
||||
|
||||
schedulePoll(delay = 1200) {
|
||||
this.clearPoll();
|
||||
this.pollTimer = setTimeout(() => this.pollStatus(), delay);
|
||||
}
|
||||
|
||||
clearPoll() {
|
||||
if (this.pollTimer) {
|
||||
clearTimeout(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
pollStatus() {
|
||||
if (!this.job) {
|
||||
return;
|
||||
}
|
||||
|
||||
const jobId = this.job.id;
|
||||
let handled = false;
|
||||
|
||||
request(`${urls.status}?job=${encodeURIComponent(jobId)}`, { silentErrors: true }, (response) => {
|
||||
handled = true;
|
||||
this.pollFailures = 0;
|
||||
|
||||
if (!response || response.status !== 'success') {
|
||||
this.schedulePoll();
|
||||
return;
|
||||
}
|
||||
|
||||
const data = response.data || {};
|
||||
const job = data.job || {};
|
||||
const progress = data.progress || {};
|
||||
|
||||
const stage = progress.stage || null;
|
||||
const status = job.status || progress.status || null;
|
||||
const operation = progress.operation || this.job.operation || null;
|
||||
|
||||
if (!this.job.snapshot && progress.snapshot) {
|
||||
this.job.snapshot = progress.snapshot;
|
||||
} else if (!this.job.snapshot && job.result && job.result.snapshot) {
|
||||
this.job.snapshot = job.result.snapshot;
|
||||
}
|
||||
|
||||
if (!this.job.label && progress.label) {
|
||||
this.job.label = progress.label;
|
||||
} else if (!this.job.label && job.result && job.result.label) {
|
||||
this.job.label = job.result.label;
|
||||
}
|
||||
|
||||
if (stage === 'error' || status === 'error') {
|
||||
const message = job.error || progress.message || (operation === 'snapshot'
|
||||
? translations.PLUGIN_ADMIN?.RESTORE_GRAV_SNAPSHOT_FAILED || 'Snapshot creation failed.'
|
||||
: translations.PLUGIN_ADMIN?.RESTORE_GRAV_FAILED || 'Snapshot restore failed.');
|
||||
toastr.error(message);
|
||||
this.job = null;
|
||||
this.clearPoll();
|
||||
return;
|
||||
}
|
||||
|
||||
if (stage === 'complete' || status === 'success') {
|
||||
if (operation === 'snapshot') {
|
||||
const snapshotId = progress.snapshot || (job.result && job.result.snapshot) || this.job.snapshot || '';
|
||||
const labelValue = progress.label || (job.result && job.result.label) || this.job.label || '';
|
||||
let displayName = labelValue || snapshotId || (translations.PLUGIN_ADMIN?.RESTORE_GRAV_TABLE_SNAPSHOT || 'snapshot');
|
||||
if (labelValue && snapshotId && labelValue !== snapshotId) {
|
||||
displayName = `${labelValue} (${snapshotId})`;
|
||||
}
|
||||
const successMessage = translations.PLUGIN_ADMIN?.RESTORE_GRAV_SNAPSHOT_SUCCESS
|
||||
? translations.PLUGIN_ADMIN.RESTORE_GRAV_SNAPSHOT_SUCCESS.replace('%s', displayName)
|
||||
: (snapshotId ? `Snapshot ${displayName} created.` : 'Snapshot created.');
|
||||
toastr.success(successMessage);
|
||||
this.job = null;
|
||||
this.clearPoll();
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshotId = progress.snapshot || this.job.snapshot || '';
|
||||
const labelValue = progress.label || (job.result && job.result.label) || this.job.label || '';
|
||||
let snapshotDisplay = snapshotId || labelValue;
|
||||
if (labelValue && snapshotId && labelValue !== snapshotId) {
|
||||
snapshotDisplay = `${labelValue} (${snapshotId})`;
|
||||
} else if (!snapshotDisplay) {
|
||||
snapshotDisplay = translations.PLUGIN_ADMIN?.RESTORE_GRAV_TABLE_SNAPSHOT || 'snapshot';
|
||||
}
|
||||
const version = (job.result && job.result.version) || progress.version || '';
|
||||
let successMessage;
|
||||
if (translations.PLUGIN_ADMIN?.RESTORE_GRAV_SUCCESS_MESSAGE && version) {
|
||||
successMessage = translations.PLUGIN_ADMIN.RESTORE_GRAV_SUCCESS_MESSAGE.replace('%1$s', snapshotDisplay).replace('%2$s', version);
|
||||
} else if (translations.PLUGIN_ADMIN?.RESTORE_GRAV_SUCCESS_SIMPLE) {
|
||||
successMessage = translations.PLUGIN_ADMIN.RESTORE_GRAV_SUCCESS_SIMPLE.replace('%s', snapshotDisplay);
|
||||
} else {
|
||||
successMessage = version ? `Snapshot ${snapshotDisplay} restored (Grav ${version}).` : `Snapshot ${snapshotDisplay} restored.`;
|
||||
}
|
||||
toastr.success(successMessage);
|
||||
this.job = null;
|
||||
this.clearPoll();
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
this.schedulePoll();
|
||||
}).then(() => {
|
||||
if (!handled) {
|
||||
this.handleSilentFailure();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleSilentFailure() {
|
||||
if (!this.job) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pollFailures += 1;
|
||||
const operation = this.job.operation || 'restore';
|
||||
const snapshot = this.job.snapshot || '';
|
||||
|
||||
if (this.pollFailures >= 3) {
|
||||
let message;
|
||||
if (operation === 'snapshot') {
|
||||
message = translations.PLUGIN_ADMIN?.RESTORE_GRAV_SNAPSHOT_FALLBACK || 'Snapshot creation may have completed. Reloading...';
|
||||
} else {
|
||||
message = snapshot
|
||||
? `Snapshot ${snapshot} restore is completing. Reloading...`
|
||||
: 'Snapshot restore is completing. Reloading...';
|
||||
}
|
||||
toastr.info(message);
|
||||
this.job = null;
|
||||
this.clearPoll();
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = Math.min(5000, 1200 * this.pollFailures);
|
||||
this.schedulePoll(delay);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize restore manager when tools view loads.
|
||||
$(document).ready(() => {
|
||||
initialiseNicetimeUpdater();
|
||||
new RestoreManager();
|
||||
});
|
||||
@@ -8,16 +8,21 @@ import Feed from './feed';
|
||||
import './check';
|
||||
import './update';
|
||||
import './channel-switcher';
|
||||
import SafeUpgrade from './safe-upgrade';
|
||||
|
||||
export default class Updates {
|
||||
constructor(payload = {}) {
|
||||
this.setPayload(payload);
|
||||
this.task = `task${config.param_sep}`;
|
||||
this.updateURL = '';
|
||||
this.safeUpgrade = new SafeUpgrade(this);
|
||||
}
|
||||
|
||||
setPayload(payload = {}) {
|
||||
this.payload = payload;
|
||||
if (this.safeUpgrade) {
|
||||
this.safeUpgrade.setPayload(payload);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
1072
themes/grav/app/updates/safe-upgrade.js
Normal file
1072
themes/grav/app/updates/safe-upgrade.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import { config } from 'grav-config';
|
||||
import { userFeedbackError } from './response';
|
||||
import { showSessionExpiredModal } from './session-expired';
|
||||
|
||||
const MAX_SAFE_DELAY = 2147483647;
|
||||
|
||||
@@ -19,14 +20,30 @@ class KeepAlive {
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
fetch() {
|
||||
checkOnce() {
|
||||
let data = new FormData();
|
||||
data.append('admin-nonce', config.admin_nonce);
|
||||
|
||||
fetch(`${config.base_url_relative}/task${config.param_sep}keepAlive`, {
|
||||
return fetch(`${config.base_url_relative}/task${config.param_sep}keepAlive`, {
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
method: 'post',
|
||||
body: data
|
||||
})
|
||||
.then((response) => {
|
||||
if (response && (response.status === 401 || response.status === 403)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
fetch() {
|
||||
return this.checkOnce().then((ok) => {
|
||||
if (!ok) { showSessionExpiredModal(); }
|
||||
}).catch(userFeedbackError);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ let request = function(url, options = {}, callback = () => true) {
|
||||
options = {};
|
||||
}
|
||||
|
||||
const silentErrors = !!options.silentErrors;
|
||||
if (options.silentErrors) {
|
||||
delete options.silentErrors;
|
||||
}
|
||||
|
||||
if (options.method && options.method === 'post') {
|
||||
let data = new FormData();
|
||||
|
||||
@@ -34,7 +39,16 @@ let request = function(url, options = {}, callback = () => true) {
|
||||
.then(parseJSON)
|
||||
.then(userFeedback)
|
||||
.then((response) => callback(response, raw))
|
||||
.catch(userFeedbackError);
|
||||
.catch((error) => {
|
||||
if (silentErrors) {
|
||||
console.debug('[Request] silent failure', url, error);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
userFeedbackError(error);
|
||||
|
||||
return undefined;
|
||||
});
|
||||
};
|
||||
|
||||
export default request;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import $ from 'jquery';
|
||||
import toastr from './toastr';
|
||||
import isOnline from './offline';
|
||||
import { config } from 'grav-config';
|
||||
// import { config } from 'grav-config';
|
||||
import trim from 'mout/string/trim';
|
||||
import { showSessionExpiredModal } from './session-expired';
|
||||
|
||||
let UNLOADING = false;
|
||||
let error = function(response) {
|
||||
@@ -25,6 +26,12 @@ export function parseStatus(response) {
|
||||
}
|
||||
|
||||
export function parseJSON(response) {
|
||||
// If the session is no longer valid, surface a blocking modal instead of generic errors
|
||||
if (response && (response.status === 401 || response.status === 403)) {
|
||||
showSessionExpiredModal();
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
return response.text().then((text) => {
|
||||
let parsed = text;
|
||||
try {
|
||||
@@ -53,7 +60,8 @@ export function userFeedback(response) {
|
||||
|
||||
switch (status) {
|
||||
case 'unauthenticated':
|
||||
document.location.href = config.base_url_relative;
|
||||
// Show a blocking modal and stop further processing
|
||||
showSessionExpiredModal();
|
||||
throw error('Logged out');
|
||||
case 'unauthorized':
|
||||
status = 'error';
|
||||
@@ -91,6 +99,13 @@ export function userFeedback(response) {
|
||||
|
||||
export function userFeedbackError(error) {
|
||||
if (UNLOADING) { return true; }
|
||||
// If we can detect an unauthorized state here, show modal
|
||||
const unauthorized = (error && (error.message === 'Unauthorized' || (error.response && (error.response.status === 401 || error.response.status === 403))));
|
||||
if (unauthorized) {
|
||||
showSessionExpiredModal();
|
||||
return;
|
||||
}
|
||||
|
||||
let stack = error.stack ? `<pre><code>${error.stack}</code></pre>` : '';
|
||||
toastr.error(`Fetch Failed: <br /> ${error.message} ${stack}`);
|
||||
console.error(`${error.message} at ${error.stack}`);
|
||||
|
||||
91
themes/grav/app/utils/session-expired.js
Normal file
91
themes/grav/app/utils/session-expired.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import $ from 'jquery';
|
||||
import { config } from 'grav-config';
|
||||
import KeepAlive from './keepalive';
|
||||
|
||||
let shown = false;
|
||||
|
||||
export function showSessionExpiredModal() {
|
||||
if (shown) { return; }
|
||||
shown = true;
|
||||
|
||||
try { localStorage.setItem('grav:admin:sessionExpiredShown', '1'); } catch (e) {}
|
||||
try { KeepAlive.stop(); } catch (e) {}
|
||||
|
||||
// Ensure modal exists (in case a custom layout removed it)
|
||||
let $modal = $('[data-remodal-id="session-expired"]');
|
||||
if (!$modal.length) {
|
||||
const html = `
|
||||
<div class="remodal" data-remodal-id="session-expired" data-remodal-options="hashTracking: false">
|
||||
<form>
|
||||
<h1>Session Expired</h1>
|
||||
<p class="bigger">Your admin login session has expired. Please log in again.</p>
|
||||
<div class="button-bar">
|
||||
<a class="button remodal-confirm" data-remodal-action="confirm" href="#">OK</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>`;
|
||||
$('body').append(html);
|
||||
$modal = $('[data-remodal-id="session-expired"]');
|
||||
}
|
||||
|
||||
// Harden the modal: no escape/overlay close
|
||||
const instance = $modal.remodal({ hashTracking: false, closeOnEscape: false, closeOnOutsideClick: false, closeOnCancel: false, closeOnConfirm: true, stack: false });
|
||||
|
||||
// Style overlay + blur background
|
||||
$('html').addClass('session-expired-active');
|
||||
$('.remodal-overlay').addClass('session-expired');
|
||||
|
||||
// On confirm, redirect to login
|
||||
$modal.off('.session-expired').on('confirmation.session-expired', () => {
|
||||
// Keep suppression flag for the next page load (login) so we don't double prompt
|
||||
window.location.href = config.base_url_relative;
|
||||
});
|
||||
|
||||
// Open modal
|
||||
instance.open();
|
||||
}
|
||||
|
||||
// Bind a jQuery global ajax error trap for legacy XHR paths
|
||||
export function bindGlobalAjaxTrap() {
|
||||
$(document).off('ajaxError._grav_session').on('ajaxError._grav_session', (event, xhr) => {
|
||||
if (!xhr) { return; }
|
||||
const status = xhr.status || 0;
|
||||
if (status === 401 || status === 403) {
|
||||
showSessionExpiredModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Intercept in-admin link clicks to show the modal before any server redirect to login
|
||||
export function installNavigationGuard() {
|
||||
$(document).off('click._grav_session_nav').on('click._grav_session_nav', 'a[href]', function(e) {
|
||||
const $a = $(this);
|
||||
const href = $a.attr('href');
|
||||
if (!href || href === '#' || href.indexOf('javascript:') === 0) { return; }
|
||||
if (e.isDefaultPrevented()) { return; }
|
||||
if ($a.attr('target') === '_blank' || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) { return; }
|
||||
|
||||
// Only guard admin-relative links
|
||||
const base = (window.GravAdmin && window.GravAdmin.config && window.GravAdmin.config.base_url_relative) || '';
|
||||
const isAdminLink = href.indexOf(base + '/') === 0 || href === base || href.indexOf('/') === 0;
|
||||
if (!isAdminLink) { return; }
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
// Quick session check, if invalid show modal, else proceed with navigation
|
||||
try {
|
||||
KeepAlive.checkOnce().then((ok) => {
|
||||
if (ok) {
|
||||
window.location.href = href;
|
||||
} else {
|
||||
showSessionExpiredModal();
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
// On any error, just navigate
|
||||
window.location.href = href;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default { showSessionExpiredModal, bindGlobalAjaxTrap };
|
||||
626
themes/grav/css-compiled/nucleus.css
vendored
626
themes/grav/css-compiled/nucleus.css
vendored
File diff suppressed because one or more lines are too long
1521
themes/grav/css-compiled/preset.css
vendored
1521
themes/grav/css-compiled/preset.css
vendored
File diff suppressed because one or more lines are too long
14
themes/grav/css-compiled/simple-fonts.css
vendored
14
themes/grav/css-compiled/simple-fonts.css
vendored
@@ -1,13 +1 @@
|
||||
body, h5, h6,
|
||||
.badge, .note, .grav-mdeditor-preview,
|
||||
input, select, textarea, button, .selectize-input,
|
||||
h1, h2, h3, h4,
|
||||
.fontfamily-sans .CodeMirror pre,
|
||||
#admin-menu li, .form-tabs > label, .label {
|
||||
font-family: "Helvetica Neue", "Helvetica", "Tahoma", "Geneva", "Arial", sans-serif; }
|
||||
|
||||
.CodeMirror pre,
|
||||
code, kbd, pre, samp, .mono {
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; }
|
||||
|
||||
/*# sourceMappingURL=simple-fonts.css.map */
|
||||
body,h5,h6,.badge,.note,.grav-mdeditor-preview,input,select,textarea,button,.selectize-input,h1,h2,h3,h4,.fontfamily-sans .CodeMirror pre,#admin-menu li,.form-tabs>label,.label{font-family:"Helvetica Neue","Helvetica","Tahoma","Geneva","Arial",sans-serif}.CodeMirror pre,code,kbd,pre,samp,.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,Courier,monospace}
|
||||
|
||||
5651
themes/grav/css-compiled/template.css
vendored
5651
themes/grav/css-compiled/template.css
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2803
themes/grav/js/admin.min.js
vendored
2803
themes/grav/js/admin.min.js
vendored
File diff suppressed because it is too large
Load Diff
107051
themes/grav/js/vendor.min.js
vendored
107051
themes/grav/js/vendor.min.js
vendored
File diff suppressed because one or more lines are too long
10620
themes/grav/package-lock.json
generated
10620
themes/grav/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,58 +5,62 @@
|
||||
"repository": "https://github.com/getgrav/grav-admin",
|
||||
"main": "app/main.js",
|
||||
"scripts": {
|
||||
"watch:sass": "sass --watch scss:css-compiled --style=expanded",
|
||||
"build:css": "sass scss:css-compiled --style=compressed --no-source-map",
|
||||
"dev": "sh -c 'npm run watch & npm run watch:sass'",
|
||||
"watch": "webpack --mode development --watch --progress --color --mode development --config webpack.conf.js",
|
||||
"prod": "webpack --mode production --config webpack.conf.js"
|
||||
},
|
||||
"author": "Trilby Media, LLC",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"babel-loader": "^8.2.3",
|
||||
"babel-loader": "^10.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"chartist": "0.11.4",
|
||||
"codemirror": "^5.65.1",
|
||||
"debounce": "^1.2.1",
|
||||
"codemirror": "^5.65.16",
|
||||
"debounce": "^2.2.0",
|
||||
"dropzone": "^5.9.3",
|
||||
"eonasdan-bootstrap-datetimepicker": "^4.17.49",
|
||||
"eventemitter3": "^4.0.7",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"events": "^3.3.0",
|
||||
"exif-js": "^2.3.0",
|
||||
"immutable": "^4.0.0",
|
||||
"immutable": "^4.3.7",
|
||||
"immutablediff": "^0.4.4",
|
||||
"js-yaml": "^4.1.0",
|
||||
"mout": "^1.2.3",
|
||||
"popper.js": "^1.14.4",
|
||||
"mout": "^1.2.4",
|
||||
"popper.js": "^1.16.1",
|
||||
"rangetouch": "^2.0.1",
|
||||
"remodal": "^1.1.1",
|
||||
"sass": "^1.92.1",
|
||||
"selectize": "^0.12.6",
|
||||
"simplebar": "^5.3.6",
|
||||
"sortablejs": "^1.14.0",
|
||||
"sortablejs": "^1.15.6",
|
||||
"speakingurl": "^14.0.1",
|
||||
"toastr": "^2.1.4",
|
||||
"watchjs": "0.0.0",
|
||||
"whatwg-fetch": "^3.6.2"
|
||||
"whatwg-fetch": "^3.6.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.17.0",
|
||||
"@babel/plugin-proposal-class-properties": "^7.16.7",
|
||||
"@babel/plugin-proposal-json-strings": "^7.16.7",
|
||||
"@babel/plugin-proposal-object-rest-spread": "^7.16.7",
|
||||
"@babel/core": "^7.28.4",
|
||||
"@babel/plugin-proposal-class-properties": "^7.18.6",
|
||||
"@babel/plugin-proposal-json-strings": "^7.18.6",
|
||||
"@babel/plugin-proposal-object-rest-spread": "^7.20.7",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/plugin-syntax-import-meta": "^7.10.4",
|
||||
"@babel/polyfill": "^7.12.1",
|
||||
"@babel/preset-env": "^7.16.11",
|
||||
"@babel/register": "^7.17.0",
|
||||
"css-loader": "^6.6.0",
|
||||
"eslint": "^8.8.0",
|
||||
"eslint-webpack-plugin": "^3.1.1",
|
||||
"exports-loader": "^3.1.0",
|
||||
"imports-loader": "^3.1.1",
|
||||
"@babel/preset-env": "^7.28.3",
|
||||
"@babel/register": "^7.28.3",
|
||||
"css-loader": "^7.1.2",
|
||||
"eslint": "^9.35.0",
|
||||
"eslint-webpack-plugin": "^5.0.2",
|
||||
"exports-loader": "^5.0.0",
|
||||
"imports-loader": "^5.0.0",
|
||||
"json-loader": "^0.5.7",
|
||||
"minimist": "^1.2.5",
|
||||
"style-loader": "^3.3.1",
|
||||
"terser-webpack-plugin": "^5.3.1",
|
||||
"webpack": "^5.68.0",
|
||||
"webpack-cli": "^4.9.2"
|
||||
"minimist": "^1.2.8",
|
||||
"style-loader": "^4.0.0",
|
||||
"terser-webpack-plugin": "^5.3.14",
|
||||
"webpack": "^5.101.3",
|
||||
"webpack-cli": "^6.0.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"minimist": "^1.2.5"
|
||||
|
||||
@@ -105,5 +105,8 @@
|
||||
// Horizontal Scroll
|
||||
@import "template/horizontal-scroll";
|
||||
|
||||
// Safe upgrade
|
||||
@import "template/safe-upgrade";
|
||||
|
||||
// Custom
|
||||
@import "template/custom";
|
||||
|
||||
@@ -20,6 +20,14 @@ $content-padding: 1.5rem;
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.error::before {
|
||||
content: "\f071";
|
||||
font-family: "FontAwesome";
|
||||
margin-right: 0.65rem;
|
||||
font-size: 1.2rem;
|
||||
color: #c0392b;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-logo {
|
||||
@@ -726,6 +734,324 @@ body.sidebar-quickopen #admin-main {
|
||||
height: calc(100vh - #{$topbar-height} - #{$update-height});
|
||||
}
|
||||
|
||||
.safe-upgrade-modal {
|
||||
text-align: left;
|
||||
|
||||
.safe-upgrade-form {
|
||||
padding: 1.5rem 1.75rem 1.5rem;
|
||||
}
|
||||
|
||||
.safe-upgrade-header {
|
||||
margin-bottom: 1.25rem;
|
||||
|
||||
h2 {
|
||||
font-size: 1.6rem;
|
||||
margin: 0 0 0.5rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.safe-upgrade-body {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
[data-safe-upgrade-footer] {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
|
||||
.button {
|
||||
min-width: 9rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
|
||||
&.is-loading {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.button-spinner {
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.safe-upgrade-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 0.75rem 1.25rem;
|
||||
background: rgba(20, 42, 68, 0.05);
|
||||
border: 1px solid rgba(20, 42, 68, 0.12);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1.35rem;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-weight: 500;
|
||||
color: #2f3b48;
|
||||
|
||||
strong {
|
||||
font-size: 1.05rem;
|
||||
margin-top: 0.2rem;
|
||||
font-weight: 600;
|
||||
color: #0e355a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.safe-upgrade-error {
|
||||
background: rgba(192, 57, 43, 0.1);
|
||||
border: 1px solid rgba(192, 57, 43, 0.35);
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
box-shadow: 0 8px 24px rgba(192, 57, 43, 0.08);
|
||||
|
||||
p {
|
||||
margin: 0 0 0.85rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #922b21;
|
||||
}
|
||||
|
||||
.button {
|
||||
min-width: 10rem;
|
||||
}
|
||||
}
|
||||
|
||||
.safe-upgrade-panel {
|
||||
--safe-upgrade-accent: #3f83d1;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(20, 42, 68, 0.14);
|
||||
border-left: 6px solid var(--safe-upgrade-accent);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem 1.15rem;
|
||||
margin-bottom: 1.15rem;
|
||||
box-shadow: 0 4px 14px rgba(15, 24, 44, 0.08);
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
&__title-wrap {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--safe-upgrade-accent);
|
||||
color: #ffffff;
|
||||
font-size: 1.2rem;
|
||||
box-shadow: 0 2px 6px rgba(15, 24, 44, 0.16);
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: block;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
display: block;
|
||||
margin-top: 0.15rem;
|
||||
font-size: 0.9rem;
|
||||
color: rgba(47, 59, 72, 0.8);
|
||||
}
|
||||
|
||||
&__body {
|
||||
margin-top: 0.85rem;
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0 0 0 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
color: #2f3b48;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: rgba(47, 59, 72, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
&__action {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
min-width: 100%;
|
||||
flex: 1 1 100%;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-left: 0;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
&--alert {
|
||||
--safe-upgrade-accent: #e67e22;
|
||||
background: rgba(230, 126, 34, 0.08);
|
||||
}
|
||||
|
||||
&--info {
|
||||
--safe-upgrade-accent: #17a2b8;
|
||||
background: rgba(23, 162, 184, 0.08);
|
||||
}
|
||||
|
||||
&--conflict {
|
||||
--safe-upgrade-accent: #8e44ad;
|
||||
background: rgba(142, 68, 173, 0.08);
|
||||
}
|
||||
|
||||
&--blocker {
|
||||
--safe-upgrade-accent: #c0392b;
|
||||
background: rgba(192, 57, 43, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.safe-upgrade-decision {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
width: 100%;
|
||||
|
||||
.safe-upgrade-decision-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.65rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border: 1px solid rgba(20, 42, 68, 0.2);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
border-color: var(--safe-upgrade-accent);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
input {
|
||||
margin-top: 0.2rem;
|
||||
flex-shrink: 0;
|
||||
accent-color: var(--safe-upgrade-accent);
|
||||
}
|
||||
|
||||
.safe-upgrade-decision-option__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.safe-upgrade-decision-option__title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: #13243b;
|
||||
}
|
||||
|
||||
.safe-upgrade-decision-option__description {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(47, 59, 72, 0.75);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.safe-upgrade-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 1.05rem;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
.safe-upgrade-progress {
|
||||
text-align: center;
|
||||
|
||||
h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.safe-upgrade-progress-bar {
|
||||
width: 100%;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 4px;
|
||||
height: 10px;
|
||||
overflow: hidden;
|
||||
margin-top: 0.75rem;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: #4dbc8b;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-value {
|
||||
font-weight: 600;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.safe-upgrade-result {
|
||||
h3 {
|
||||
margin-bottom: 0.25rem;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
&.success h3 {
|
||||
color: #45b854;
|
||||
}
|
||||
|
||||
&.error h3 {
|
||||
color: #922b21;
|
||||
}
|
||||
|
||||
&.neutral h3 {
|
||||
color: #6c7a89;
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: rgba(192, 57, 43, 0.08);
|
||||
border: 1px solid rgba(192, 57, 43, 0.22);
|
||||
border-radius: 10px;
|
||||
padding: 1.2rem 1.4rem;
|
||||
box-shadow: 0 12px 32px rgba(192, 57, 43, 0.12);
|
||||
margin-bottom: 1rem;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: rgba(47, 59, 72, 0.85);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
position: relative;
|
||||
//overflow-y: hidden;
|
||||
@@ -1435,4 +1761,16 @@ body.sidebar-quickopen #admin-main {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.simplebar-content-wrapper { overflow: auto; }
|
||||
/* Session expired blur + overlay */
|
||||
html.session-expired-active .remodal-bg { filter: blur(4px); pointer-events: none; user-select: none; }
|
||||
html.session-expired-active #admin-login { filter: blur(4px); pointer-events: none; user-select: none; }
|
||||
.remodal-overlay.session-expired { background: rgba(0,0,0,0.55); backdrop-filter: blur(3px); }
|
||||
[data-remodal-id="session-expired"] .button-bar .button { min-width: 120px;text-align: center; }
|
||||
/* Lightweight modal used on login page when vendor JS is not loaded */
|
||||
.grav-expired-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.55); backdrop-filter: blur(3px); z-index: 9999; display: flex; align-items: center; justify-content: center; }
|
||||
.grav-expired-modal { background: #fff; border-radius: 6px; width: min(640px, 90%); box-shadow: 0 10px 30px rgba(0,0,0,.25); overflow: hidden; font-family: inherit; }
|
||||
.grav-expired-modal h1 { margin: 0; padding: 24px 28px; font-size: 28px; color: #3D424E; border-bottom: 1px solid #f0f0f0; }
|
||||
.grav-expired-modal p { margin: 0; padding: 20px 28px; font-size: 16px; color: #6f7b8a; }
|
||||
.grav-expired-actions { padding: 18px 24px; display: flex; justify-content: flex-end; background: #f7f7f7; }
|
||||
.grav-expired-actions .button { min-width: 120px;text-align: center; }
|
||||
|
||||
@@ -429,7 +429,7 @@ form {
|
||||
.dynfields, [data-grav-field="array"], [data-grav-field="multilevel"] {
|
||||
|
||||
input[type=text] {
|
||||
width: 40%;
|
||||
width: calc(50% - 50px);
|
||||
float: left;
|
||||
margin: 0 5px 5px 0;
|
||||
}
|
||||
@@ -445,6 +445,7 @@ form {
|
||||
display: inline-block;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
margin-right: 8px;
|
||||
|
||||
&[data-grav-array-action="sort"] {
|
||||
float: left;
|
||||
@@ -453,7 +454,7 @@ form {
|
||||
}
|
||||
|
||||
&.array-field-value_only {
|
||||
width: 100%;
|
||||
width: calc(100% - 100px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,4 +326,5 @@ html.remodal-is-locked {
|
||||
.remodal ul li {
|
||||
margin-left: 27px;
|
||||
list-style-type: square;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
423
themes/grav/scss/template/_safe-upgrade.scss
Normal file
423
themes/grav/scss/template/_safe-upgrade.scss
Normal file
@@ -0,0 +1,423 @@
|
||||
.remodal {
|
||||
.safe-upgrade-header, .safe-upgrade-body {
|
||||
padding: 0 1.5rem;
|
||||
h2 {
|
||||
padding-top: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
p {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
.safe-upgrade-body {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.safe-upgrade-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 0.75rem 1.25rem;
|
||||
background: rgba(20, 42, 68, 0.05);
|
||||
border: 1px solid rgba(20, 42, 68, 0.12);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1.35rem;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-weight: 500;
|
||||
color: #2f3b48;
|
||||
|
||||
strong {
|
||||
font-size: 1.05rem;
|
||||
margin-top: 0.2rem;
|
||||
font-weight: 600;
|
||||
color: #0e355a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.safe-upgrade-error {
|
||||
background: rgba(192, 57, 43, 0.1);
|
||||
border: 1px solid rgba(192, 57, 43, 0.35);
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
box-shadow: 0 8px 24px rgba(192, 57, 43, 0.08);
|
||||
text-align: center;
|
||||
|
||||
p {
|
||||
margin: 0 0 0.85rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #922b21;
|
||||
}
|
||||
|
||||
.button {
|
||||
min-width: 10rem;
|
||||
}
|
||||
}
|
||||
|
||||
[data-safe-upgrade-footer] {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
|
||||
.button {
|
||||
min-width: 9rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
|
||||
&.is-loading {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.button-spinner {
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.safe-upgrade-panel {
|
||||
--safe-upgrade-accent: #3f83d1;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(20, 42, 68, 0.14);
|
||||
border-left: 6px solid var(--safe-upgrade-accent);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem 1.15rem;
|
||||
margin-bottom: 1.15rem;
|
||||
box-shadow: 0 4px 14px rgba(15, 24, 44, 0.08);
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
&__title-wrap {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--safe-upgrade-accent);
|
||||
color: #ffffff;
|
||||
font-size: 1.2rem;
|
||||
box-shadow: 0 2px 6px rgba(15, 24, 44, 0.16);
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: block;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
display: block;
|
||||
margin-top: 0.15rem;
|
||||
font-size: 0.9rem;
|
||||
color: rgba(47, 59, 72, 0.8);
|
||||
}
|
||||
|
||||
&__body {
|
||||
margin-top: 0.85rem;
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0 0 0 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
color: #2f3b48;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: rgba(47, 59, 72, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
&__action {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
min-width: 100%;
|
||||
flex: 1 1 100%;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-left: 0;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
&--alert {
|
||||
--safe-upgrade-accent: #e67e22;
|
||||
background: rgba(230, 126, 34, 0.08);
|
||||
}
|
||||
|
||||
&--info {
|
||||
--safe-upgrade-accent: #17a2b8;
|
||||
background: rgba(23, 162, 184, 0.08);
|
||||
}
|
||||
|
||||
&--conflict {
|
||||
--safe-upgrade-accent: #8e44ad;
|
||||
background: rgba(142, 68, 173, 0.08);
|
||||
}
|
||||
|
||||
&--blocker {
|
||||
--safe-upgrade-accent: #c0392b;
|
||||
background: rgba(192, 57, 43, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.safe-upgrade-decision {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
width: 100%;
|
||||
|
||||
.safe-upgrade-decision-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.65rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border: 1px solid rgba(20, 42, 68, 0.2);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
border-color: var(--safe-upgrade-accent);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
input {
|
||||
margin-top: 0.2rem;
|
||||
flex-shrink: 0;
|
||||
accent-color: var(--safe-upgrade-accent);
|
||||
}
|
||||
|
||||
.safe-upgrade-decision-option__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.safe-upgrade-decision-option__title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: #13243b;
|
||||
}
|
||||
|
||||
.safe-upgrade-decision-option__description {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(47, 59, 72, 0.75);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.safe-upgrade-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 1.05rem;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
.safe-upgrade-progress {
|
||||
text-align: center;
|
||||
|
||||
h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.safe-upgrade-progress-bar {
|
||||
width: 100%;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 4px;
|
||||
height: 10px;
|
||||
overflow: hidden;
|
||||
margin-top: 0.75rem;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: #4dbc8b;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
&.is-active span {
|
||||
animation: safe-upgrade-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.safe-upgrade-status {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-value {
|
||||
font-weight: 600;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.safe-upgrade-result {
|
||||
h3 {
|
||||
margin-bottom: 0.25rem;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
&.success {
|
||||
background: rgba(41, 182, 94, 0.08);
|
||||
border: 1px solid rgba(41, 182, 94, 0.24);
|
||||
border-radius: 12px;
|
||||
padding: 1.3rem 1.4rem;
|
||||
box-shadow: 0 14px 32px rgba(41, 182, 94, 0.18);
|
||||
margin-bottom: 1rem;
|
||||
text-align: left;
|
||||
|
||||
.safe-upgrade-result__banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.safe-upgrade-result__icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: #27ae60;
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.25rem;
|
||||
box-shadow: 0 10px 22px rgba(39, 174, 96, 0.35);
|
||||
}
|
||||
|
||||
.safe-upgrade-result__label {
|
||||
font-size: 0.82rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.15rem;
|
||||
color: rgba(39, 174, 96, 0.85);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
color: #14301d;
|
||||
}
|
||||
|
||||
.safe-upgrade-result__details {
|
||||
font-size: 0.95rem;
|
||||
color: rgba(20, 48, 29, 0.9);
|
||||
|
||||
p {
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: rgba(192, 57, 43, 0.08);
|
||||
border: 1px solid rgba(192, 57, 43, 0.22);
|
||||
border-radius: 10px;
|
||||
padding: 1.2rem 1.4rem;
|
||||
box-shadow: 0 12px 32px rgba(192, 57, 43, 0.12);
|
||||
margin-bottom: 1rem;
|
||||
text-align: left;
|
||||
|
||||
h3 {
|
||||
color: #922b21;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: rgba(47, 59, 72, 0.85);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.restore-grav-content {
|
||||
padding: 0 1.5rem 2rem;
|
||||
|
||||
.button-bar {
|
||||
margin: 1.5rem 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.restore-grav-intro {
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.restore-grav-table {
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
code {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
table.restore-grav-table {
|
||||
.checkbox-cell {
|
||||
flex: .1;
|
||||
& + td + td, & + th + th {
|
||||
flex: 0.5;
|
||||
}
|
||||
}
|
||||
.actions-cell {
|
||||
flex: .5;
|
||||
}
|
||||
.hint {
|
||||
opacity: 0.7;
|
||||
size: 90%;
|
||||
&:before, &:after {
|
||||
content: '';
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes safe-upgrade-pulse {
|
||||
0% {
|
||||
filter: brightness(100%);
|
||||
}
|
||||
50% {
|
||||
filter: brightness(115%);
|
||||
}
|
||||
100% {
|
||||
filter: brightness(100%);
|
||||
}
|
||||
}
|
||||
@@ -33,12 +33,6 @@
|
||||
{{ assets.js()|raw }}
|
||||
{% endblock %}
|
||||
|
||||
<style>
|
||||
.simplebar-content-wrapper {
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
{% block body %}
|
||||
{% set sidebarStatus = get_cookie('grav-admin-sidebar') %}
|
||||
@@ -89,6 +83,16 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block modals %}
|
||||
{# Session expired blocking modal #}
|
||||
<div class="remodal" data-remodal-id="session-expired" data-remodal-options="hashTracking: false">
|
||||
<form>
|
||||
<h1>{{ 'PLUGIN_ADMIN.SESSION_EXPIRED'|t|default('Session Expired') }}</h1>
|
||||
<p class="bigger">{{ 'PLUGIN_ADMIN.SESSION_EXPIRED_DESC'|t|default('Your admin login session has expired. Click OK to log in again.') }}</p>
|
||||
<div class="button-bar">
|
||||
<a class="button remodal-confirm" data-remodal-action="confirm" href="#">{{ 'PLUGIN_ADMIN.OK'|t|default('OK') }}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="remodal" data-remodal-id="generic" data-remodal-options="hashTracking: false">
|
||||
<form>
|
||||
<h1>{{ "PLUGIN_ADMIN.ERROR"|t }}</h1>
|
||||
@@ -123,16 +127,22 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="remodal" data-remodal-id="update-grav" data-remodal-options="hashTracking: false">
|
||||
<form>
|
||||
<h1>{{ "PLUGIN_ADMIN.MODAL_DELETE_FILE_CONFIRMATION_REQUIRED_TITLE"|t }}</h1>
|
||||
<p class="bigger">
|
||||
{{ "PLUGIN_ADMIN.MODAL_UPDATE_GRAV_CONFIRMATION_REQUIRED_DESC"|t }}
|
||||
</p>
|
||||
<br>
|
||||
<div class="button-bar">
|
||||
<button data-remodal-action="cancel" class="button secondary remodal-cancel"><i class="fa fa-fw fa-close"></i> {{ "PLUGIN_ADMIN.CANCEL"|t }}</button>
|
||||
<button data-remodal-action="confirm" class="button remodal-confirm disable-after-click"><i class="fa fa-fw fa-check"></i> {{ "PLUGIN_ADMIN.CONTINUE"|t }}</button>
|
||||
<div class="remodal safe-upgrade-modal" data-remodal-id="update-grav" data-remodal-options="hashTracking: false">
|
||||
<form class="safe-upgrade-form">
|
||||
<div class="safe-upgrade-header">
|
||||
<h2>{{ "PLUGIN_ADMIN.SAFE_UPGRADE_TITLE"|t }}</h2>
|
||||
<p class="bigger">{{ "PLUGIN_ADMIN.SAFE_UPGRADE_DESC"|t }}</p>
|
||||
</div>
|
||||
<div class="safe-upgrade-body">
|
||||
<section data-safe-upgrade-step="preflight"></section>
|
||||
<section data-safe-upgrade-step="progress" class="hidden"></section>
|
||||
<section data-safe-upgrade-step="result" class="hidden"></section>
|
||||
</div>
|
||||
<div class="button-bar" data-safe-upgrade-footer>
|
||||
<button data-remodal-action="cancel" data-safe-upgrade-action="cancel" class="button secondary remodal-cancel"><i class="fa fa-fw fa-close"></i> {{ "PLUGIN_ADMIN.CANCEL"|t }}</button>
|
||||
<button data-safe-upgrade-action="recheck" class="button secondary">{{ "PLUGIN_ADMIN.SAFE_UPGRADE_RECHECK"|t }}</button>
|
||||
<button data-safe-upgrade-action="start" class="button primary hidden" disabled>{{ "PLUGIN_ADMIN.SAFE_UPGRADE_START"|t }}</button>
|
||||
<button data-safe-upgrade-action="finish" class="button primary hidden">{{ "PLUGIN_ADMIN.SAFE_UPGRADE_FINISH"|t }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</div>
|
||||
<h1>{{ "PLUGIN_ADMIN.LATEST_PAGE_UPDATES"|t }}</h1>
|
||||
<table>
|
||||
{% for latest in admin.latestPages if admin.latestPages %}
|
||||
{% for latest in admin.latestPages|default([]) %}
|
||||
{% set route = latest.rawRoute %}
|
||||
<tr>
|
||||
<td class="triple page-title">
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
enable_auto_updates_check: '{{ config.plugins.admin.enable_auto_updates_check }}',
|
||||
{% endif %}
|
||||
admin_timeout: '{{ config.plugins.admin.session.timeout ?: 1800 }}',
|
||||
keep_alive_enabled: {{ (config.plugins.admin.session.keep_alive is defined ? config.plugins.admin.session.keep_alive : true) ? 1 : 0 }},
|
||||
admin_nonce: '{{ admin.getNonce }}',
|
||||
language: '{{ grav.user.language|default('en') }}',
|
||||
pro_enabled: '{{ config.plugins["admin-pro"].enabled }}',
|
||||
|
||||
@@ -1,11 +1,47 @@
|
||||
{% do assets.add('jquery',101) %}
|
||||
{% if authorize(['admin.login', 'admin.super']) %}
|
||||
{% do assets.addJs(theme_url~'/js/vendor.min.js', { 'loading':'defer' }) %}
|
||||
{% do assets.addJs(theme_url~'/js/admin.min.js' , { 'loading':'defer' }) %}
|
||||
{% do assets.addJs(theme_url~'/js/vendor.min.js', { 'loading':'defer' }) %}
|
||||
{% do assets.addJs(theme_url~'/js/admin.min.js' , { 'loading':'defer' }) %}
|
||||
|
||||
{% if browser.getBrowser == 'msie' or browser.getBrowser == 'edge' %}
|
||||
{% do assets.addJs(theme_url~'/js/form-attr.polyfill.js') %}
|
||||
{% endif %}
|
||||
{% if browser.getBrowser == 'msie' or browser.getBrowser == 'edge' %}
|
||||
{% do assets.addJs(theme_url~'/js/form-attr.polyfill.js') %}
|
||||
{% endif %}
|
||||
|
||||
{% include 'partials/javascripts-extra.html.twig' ignore missing %}
|
||||
{% include 'partials/javascripts-extra.html.twig' ignore missing %}
|
||||
{% else %}
|
||||
{# Not authorized (e.g., login page). Keep session + login nonce fresh. No session-expired overlay here. #}
|
||||
<script>
|
||||
(function() {
|
||||
var base = '{{ base_url_relative }}';
|
||||
var sep = '{{ config.system.param_sep }}';
|
||||
// Use Admin's configured timeout if set; fall back to system session timeout
|
||||
var adminTimeout = {{ (config.plugins.admin.session.timeout is defined ? config.plugins.admin.session.timeout : (config.system.session.timeout|default(1800))) }}; // seconds
|
||||
// Refresh faster than the admin timeout; aim for ~1/3 of it, but at least 2s and at most 20s
|
||||
var interval = Math.max(2, Math.min(20, Math.floor(adminTimeout / 3)));
|
||||
|
||||
function refreshLoginNonce() {
|
||||
var url = base + '/task' + sep + 'nonce?ts=' + Date.now();
|
||||
fetch(url, { credentials: 'same-origin', headers: { 'Accept': 'application/json', 'Cache-Control': 'no-cache' }})
|
||||
.then(function(r){ return r.ok ? r.json() : null; })
|
||||
.then(function(data){
|
||||
if (!data || !data.nonce) { return; }
|
||||
var name = (data.nonce_name || 'login-nonce');
|
||||
var inputs = document.querySelectorAll('input[name="' + name + '"]');
|
||||
inputs.forEach(function(i){ i.value = data.nonce; });
|
||||
})
|
||||
.catch(function(){ /* silent */ });
|
||||
}
|
||||
|
||||
function boot() {
|
||||
if (document.getElementById('admin-login')) {
|
||||
refreshLoginNonce();
|
||||
setInterval(refreshLoginNonce, interval * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else { boot(); }
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
71
themes/grav/templates/partials/tools-restore-grav.html.twig
Normal file
71
themes/grav/templates/partials/tools-restore-grav.html.twig
Normal file
@@ -0,0 +1,71 @@
|
||||
<h1>{{ "PLUGIN_ADMIN.RESTORE_GRAV"|t }}</h1>
|
||||
|
||||
<div class="restore-grav-content">
|
||||
{% set snapshots = admin.safeUpgradeSnapshots() %}
|
||||
|
||||
{% if snapshots %}
|
||||
<p class="restore-grav-intro">
|
||||
{{ "PLUGIN_ADMIN.RESTORE_GRAV_DESC"|t }}
|
||||
</p>
|
||||
|
||||
<form id="snapshot-delete-form" action="{{ admin_route('/tools/restore-grav') }}" method="post" class="snapshot-delete-form">
|
||||
<input type="hidden" name="task" value="safeUpgradeDelete">
|
||||
{{ nonce_field('admin-form', 'admin-nonce')|raw }}
|
||||
</form>
|
||||
|
||||
<table class="restore-grav-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="checkbox-cell"></th>
|
||||
<th>{{ "PLUGIN_ADMIN.RESTORE_GRAV_TABLE_SNAPSHOT"|t }}</th>
|
||||
<th>{{ "PLUGIN_ADMIN.RESTORE_GRAV_TABLE_VERSION"|t }}</th>
|
||||
<th>{{ "PLUGIN_ADMIN.RESTORE_GRAV_TABLE_CREATED"|t }}</th>
|
||||
<th class="actions-cell">{{ "PLUGIN_ADMIN.RESTORE_GRAV_TABLE_ACTIONS"|t }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for snapshot in snapshots %}
|
||||
{% set version = snapshot.source_version ?: snapshot.target_version %}
|
||||
<tr>
|
||||
<td class="checkbox-cell">
|
||||
<input type="checkbox" name="snapshots[]" value="{{ snapshot.id }}" form="snapshot-delete-form" />
|
||||
</td>
|
||||
<td>
|
||||
{% if snapshot.label %}
|
||||
<strong>{{ snapshot.label }}</strong><br>
|
||||
<code>{{ snapshot.id }}</code>
|
||||
{% else %}
|
||||
<code>{{ snapshot.id }}</code>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ version ?: "PLUGIN_ADMIN.UNKNOWN"|t }}</td>
|
||||
<td>
|
||||
{% if snapshot.created_at %}
|
||||
{{ snapshot.created_at|date('Y-m-d H:i:s') }}
|
||||
<span class="hint" data-nicetime-timestamp="{{ snapshot.created_at|date('c') }}">{{ snapshot.created_at|nicetime(false, false) }}</span>
|
||||
{% else %}
|
||||
{{ "PLUGIN_ADMIN.UNKNOWN"|t }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="actions-cell">
|
||||
<button type="button" class="button" data-restore-snapshot="{{ snapshot.id }}">{{ "PLUGIN_ADMIN.RESTORE_GRAV_RESTORE_BUTTON"|t }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="button-bar">
|
||||
<button type="button" class="button primary" data-create-snapshot>
|
||||
{{ "PLUGIN_ADMIN.RESTORE_GRAV_CREATE_SNAPSHOT"|t }}
|
||||
</button>
|
||||
<button type="submit" form="snapshot-delete-form" class="button danger" name="task" value="safeUpgradeDelete">
|
||||
{{ "PLUGIN_ADMIN.RESTORE_GRAV_DELETE_SELECTED"|t }}
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="notice secondary">
|
||||
<p>{{ "PLUGIN_ADMIN.RESTORE_GRAV_NONE"|t }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
{% set tools_slug = uri.basename %}
|
||||
{% if tools_slug == 'tools' %}{% set tools_slug = 'backups' %}{% endif %}
|
||||
{% set title = "PLUGIN_ADMIN.TOOLS"|t ~ ": " ~ ("PLUGIN_ADMIN." ~ tools_slug|underscorize|upper)|t %}
|
||||
{% set tools = admin.tools() %}
|
||||
{% if tools[tools_slug] is not defined %}
|
||||
{% set tools_slug = tools|keys|first %}
|
||||
{% endif %}
|
||||
{% set title = "PLUGIN_ADMIN.TOOLS"|t ~ ": " ~ ("PLUGIN_ADMIN." ~ tools_slug|underscorize|upper)|t %}
|
||||
|
||||
{% set titlebar -%}
|
||||
{% include 'partials/tools-' ~ tools_slug ~ '-titlebar.html.twig' ignore missing %}
|
||||
@@ -36,6 +39,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ dump(tools) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@@ -47,4 +51,3 @@
|
||||
<h1>Unauthorized</h1>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Configuration
|
||||
#
|
||||
|
||||
# sass source
|
||||
|
||||
SASS_SOURCE_PATH="scss"
|
||||
|
||||
# sass options
|
||||
SASS_OPTIONS="--source-map=true --style=nested"
|
||||
|
||||
# css target
|
||||
CSS_TARGET_PATH="css-compiled"
|
||||
|
||||
#
|
||||
# Check prerequisites
|
||||
#
|
||||
wtfile=$(command -v wt) || { echo "install wellington with 'brew install wellington"; exit 1; }
|
||||
|
||||
#
|
||||
# Watch folder for changes
|
||||
#
|
||||
cd -P `pwd`
|
||||
$wtfile compile "$SASS_SOURCE_PATH" -b "$CSS_TARGET_PATH" $SASS_OPTIONS
|
||||
$wtfile watch "$SASS_SOURCE_PATH" -b "$CSS_TARGET_PATH" $SASS_OPTIONS
|
||||
5
themes/grav/webpack.conf.js
vendored
5
themes/grav/webpack.conf.js
vendored
@@ -33,10 +33,7 @@ module.exports = (env, argv) => ({
|
||||
jquery: 'jQuery',
|
||||
'grav-config': 'GravAdmin'
|
||||
},
|
||||
plugins: [new ESLintPlugin({
|
||||
extensions: ['js', 'jsx'],
|
||||
exclude: ['/node_modules/']
|
||||
})],
|
||||
plugins: [],
|
||||
module: {
|
||||
rules: [
|
||||
{ enforce: 'pre', test: /\.json$/, loader: 'json-loader' },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user