Compare commits

..

44 Commits

Author SHA1 Message Date
dillard 2c3e6a245b fix(config): clear the old category cache on upgrade
The old cache is only replaced when the categories are requested, so
on instances that use a menu without categories the 170 KB app config
value stayed and was still loaded on every request. A post-migration
repair step clears it when the app is upgraded.
2026-09-30 13:32:40 +02:00
dillard 80f6dcf73a fix(config): refresh a category cache stored with another type
If cache-categories was stored with a type other than mixed or array,
for example set by hand with occ, the typed read throws. Treat that as
a missing cache so it is refreshed instead of breaking the menu and the
settings page.
2026-09-30 13:30:01 +02:00
dillard 73dea84d89 fix(config): keep the category cache in the memory cache when available
A cache belongs in Nextcloud's memory cache rather than in the app
config. Use the distributed cache (e.g. Redis) with a one-week TTL when
one is available; Nextcloud falls back to the local cache (APCu) when
only that is configured. Without any memory cache the lazy app config
value stays as the fallback. A leftover app config value is removed
when the categories are stored in the memory cache, and saving the
settings clears both.
2026-09-30 13:26:45 +02:00
dillard 22e87e12ad fix(config): only cache category names in the languages in use
The cache held the category names in all 96 app store languages
(about 49 KB), while an instance typically uses one or two. Keep only
English, the default language and the languages users have set, as the
two-letter codes the lookup uses; on a Dutch instance this is about
1.3 KB.

A user who switches to a language that is not cached yet sees English
names until the cache is refreshed after a week, or right away when an
admin saves the settings. The README mentions this.
2026-09-30 13:17:37 +02:00
dillard ccefefa6f7 fix(config): keep the category cache small and save settings at once
The app store category cache (cache-categories) held the name and
description of every category in every language, about 170 KB, stored
as a regular app config value. Nextcloud loads all regular values of
all apps on every request, so this one value was read on every page
load. It was only refreshed with the "remove cache" button.

- Keep only the category ids and names, store the cache as a lazy
  value so it is only loaded when categories are needed, and refresh
  it after a week.
- Only load the categories where they are used: the menus with
  categories, and the app list when custom categories are set.
- Save all settings in one request to a new admin endpoint, which
  accepts only known settings, then clears the category cache and
  changes the stylesheet cache key. The settings page sent one request
  per setting and reloaded right after; with Nextcloud's short-lived
  local config cache, the reloaded page could show a half-saved
  configuration, so a change sometimes had to be made twice.
2026-09-30 12:01:50 +02:00
Simon Vieille d666cac5be Merge pull request 'fix #510 #520 #417' (#521) from feature/nc35 into develop
Reviewed-on: https://gitnet.fr/deblan/side_menu/pulls/521
2026-09-18 18:33:50 +02:00
Simon Vieille 9a71429182 doc: update changelog 2026-09-18 18:30:48 +02:00
Simon Vieille f1b495809f fix #510: [Bug] Styling of the icon labels and overflow icon is not correct 2026-09-18 18:28:08 +02:00
Simon Vieille e44b2c70a7 feat(app): add compatibility with Nextcloud 35 2026-09-18 18:06:54 +02:00
Simon Vieille 52de7326ec Merge pull request 'feat: release v6.1.0' (#518) from feature/release into develop
Reviewed-on: https://gitnet.fr/deblan/side_menu/pulls/518
2026-09-07 18:07:26 +02:00
Simon Vieille 4975743441 feat: release v6.1.0 2026-09-07 18:06:55 +02:00
Simon Vieille be6e9eaced Merge pull request 'fix(admin): add toggle menu button for admin navigation (#462)' (#517) from bugfix/issue462-adminmenu into develop
Reviewed-on: https://gitnet.fr/deblan/side_menu/pulls/517
2026-09-05 16:46:37 +02:00
Simon Vieille 627b9e8bf4 fix(admin): add toggle menu button for admin navigation (#462) 2026-09-05 16:44:52 +02:00
Simon Vieille 7bc64003a5 Merge pull request 'bugfix/issue510-styling' (#516) from bugfix/issue510-styling into develop
Reviewed-on: https://gitnet.fr/deblan/side_menu/pulls/516
2026-09-05 15:56:05 +02:00
Simon Vieille d078cc68e7 fix(StandardMenu): move 'more apps' buttons down (#510) 2026-09-05 15:54:26 +02:00
Simon Vieille 55a2b21ed1 fix(StandardMenu): move app labels down (#510) 2026-09-05 15:43:35 +02:00
Simon Vieille f7a8a41f6e merge develop 2026-09-05 13:16:38 +02:00
Simon Vieille c2c6399106 Merge pull request 'feat: add search toggle option (fix #509)' (#515) from feature/issue509-search-toggle into develop
Reviewed-on: https://gitnet.fr/deblan/side_menu/pulls/515
2026-09-05 13:09:52 +02:00
Simon Vieille f4833d1956 feat: add search toggle option (fix #509) 2026-09-05 13:06:24 +02:00
Simon Vieille 9eab478c36 chore(info): set version 2026-07-18 16:28:51 +02:00
Simon Vieille c1588dfc2e Merge pull request 'feat(standard-menu): add unified-search-input size handling' (#507) from bugfix/top-menu-popover into develop
Reviewed-on: https://gitnet.fr/deblan/side_menu/pulls/507
2026-07-18 16:26:18 +02:00
Simon Vieille c24bb6f8c8 feat(standard-menu): add unified-search-input size handling 2026-07-18 16:26:06 +02:00
Simon Vieille ca689d8b6f Merge pull request 'bugfix/top-menu-popover' (#506) from bugfix/top-menu-popover into develop
Reviewed-on: https://gitnet.fr/deblan/side_menu/pulls/506
2026-07-18 15:52:52 +02:00
Simon Vieille e81f0e4b76 doc: update changelog 2026-07-18 15:52:34 +02:00
Simon Vieille 7033569bed feat(standard-menu): add unified-search-input size handling 2026-07-18 15:52:21 +02:00
Simon Vieille a9788f0ab8 Merge pull request 'bugfix/always-displays-no-apps' (#505) from bugfix/always-displays-no-apps into develop
Reviewed-on: https://gitnet.fr/deblan/side_menu/pulls/505
2026-07-18 13:03:22 +02:00
Simon Vieille 1a8063bd94 doc: update changelog 2026-07-18 13:00:51 +02:00
Simon Vieille cc841570ec fix(ui): only apply always-displayed display css rules when the menu is displayed 2026-07-18 13:00:35 +02:00
Simon Vieille d6a2da402c fix(webpack): set global appVersion 2026-07-18 12:44:22 +02:00
Simon Vieille fef11cd0cf Merge pull request 'feature/nc34' (#503) from feature/nc34 into develop
Reviewed-on: https://gitnet.fr/deblan/side_menu/pulls/503
2026-07-17 18:59:17 +02:00
Simon Vieille 12b35470c7 doc: update changelog 2026-07-17 18:58:42 +02:00
Simon Vieille 23cb8fcfc1 chore(app): remove compatibility with Nextcloud < 34 2026-07-17 18:58:24 +02:00
Simon Vieille 2b4da9cfce Merge pull request 'chore(info): set version' (#502) from feature/nc34 into develop
Reviewed-on: https://gitnet.fr/deblan/side_menu/pulls/502
2026-07-17 18:45:08 +02:00
Simon Vieille f2af45711d chore(info): set version 2026-07-17 18:44:57 +02:00
Simon Vieille 35afa38f73 Merge pull request 'feature/nc34' (#500) from feature/nc34 into develop
Reviewed-on: https://gitnet.fr/deblan/side_menu/pulls/500
2026-07-17 18:43:53 +02:00
Simon Vieille ad162f5821 doc: update changelog 2026-07-17 18:34:53 +02:00
Simon Vieille d3f87adcca feat(app): add compatibility with Nextcloud 34 2026-07-17 18:34:53 +02:00
Simon Vieille b32e4ded42 fix(app): add compatibility with Nextcloud 34 2026-07-17 18:31:25 +02:00
Simon Vieille 88e54e01b6 chore(dep): update @nextcloud/vue 2026-07-17 18:29:11 +02:00
Simon Vieille 4d453676dc Merge branch 'develop' into translations 2026-03-08 19:08:08 +01:00
gallegonovato 0cb14258ad Translated using Weblate (Spanish)
Currently translated at 100.0% (111 of 111 strings)

Translation: Custom menu/Application
Translate-URL: https://translate.codeberg.org/projects/custom-menu/application/es/
2026-02-13 18:29:09 +00:00
Outbreak2096 7e7397735b Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (111 of 111 strings)

Translation: Custom menu/Application
Translate-URL: https://translate.codeberg.org/projects/custom-menu/application/zh_Hans/
2025-11-21 16:10:09 +00:00
Codeberg Translate ccfb541404 Merge branch 'origin/translations' into Weblate. 2025-09-30 21:16:55 +00:00
gallegonovato f4e35bdc5d Translated using Weblate (Spanish)
Currently translated at 100.0% (111 of 111 strings)

Translation: Custom menu/Application
Translate-URL: https://translate.codeberg.org/projects/custom-menu/application/es/
2025-09-30 21:16:53 +00:00
33 changed files with 2688 additions and 895 deletions
+648 -253
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -49,6 +49,8 @@ Users can disable the menu using the page of personal settings.
Use the shortcut `Ctrl`+`o` to open and to hide the side menu. Use `tab` to navigate.
The names of the app store categories are cached for a week, only in the languages that are in use on the instance (and English). If a user switches to a new language and sees English category names, save the settings on the administration page to refresh the cache right away.
### Use first top menu app as default app
You can easily let Custom Menu redirect to the first app in the top menu by changing the following parameter in your `config/config.php`:
+7 -2
View File
@@ -30,7 +30,7 @@ Notice
Because I believe in a free and decentralized Internet, [Gitnet](https://gitnet.fr) is **self-hosted at home**.
In case of downtime, you can download **Custom Menu** from [here](https://kim.deblan.fr/~side_menu/).
]]></description>
<version>5.3.0</version>
<version>6.2.0</version>
<licence>agpl</licence>
<author mail="contact@deblan.fr" homepage="https://www.deblan.fr/">Simon Vieille</author>
<namespace>SideMenu</namespace>
@@ -53,8 +53,13 @@ In case of downtime, you can download **Custom Menu** from [here](https://kim.de
<screenshot><![CDATA[https://gitnet.fr/deblan/side_menu/raw/branch/master/screenshots/nc25_default_menu.png]]></screenshot>
<dependencies>
<php min-version="8.1" max-version="8.5" />
<nextcloud min-version="31" max-version="33"/>
<nextcloud min-version="34" max-version="35"/>
</dependencies>
<repair-steps>
<post-migration>
<step>OCA\SideMenu\Migration\ClearCategoryCache</step>
</post-migration>
</repair-steps>
<settings>
<admin>OCA\SideMenu\Settings\Admin</admin>
<admin-section>OCA\SideMenu\Settings\AdminSection</admin-section>
+19 -8
View File
@@ -6,21 +6,29 @@ use OC\AllConfig;
use OC\App\AppStore\Fetcher\CategoryFetcher;
use OC\AppFramework\Http\Request;
use OC\Security\CSP\ContentSecurityPolicyNonceManager;
use OC\Security\CSRF\CsrfTokenManager;
use OC\User\User;
use OCA\SideMenu\Service\AppRepository;
use OCA\SideMenu\Service\CategoryRepository;
use OCA\SideMenu\Service\Color;
use OCA\SideMenu\Service\ConfigProxy;
use OCA\SideMenu\Service\LangRepository;
use OCA\Theming\ThemingDefaults;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IAppConfig;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\INavigationManager;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUserSession;
use OCP\L10N\IFactory;
use OCP\Server;
use OCP\Util;
use Psr\Container\ContainerInterface;
@@ -34,9 +42,9 @@ class Application extends App implements IBootstrap
public const APP_ID = 'side_menu';
public const APP_NAME = 'Custom menu';
protected AllConfig $config;
protected IConfig $config;
protected ContentSecurityPolicyNonceManager $cspnm;
protected Request $request;
protected IRequest $request;
protected ?User $user = null;
public function __construct(array $urlParams = [])
@@ -52,7 +60,11 @@ class Application extends App implements IBootstrap
$c->get(ConfigProxy::class),
$c->get(IConfig::class),
$c->get(IFactory::class),
$c->get(IUserSession::class)
$c->get(IUserSession::class),
$c->get(IAppConfig::class),
$c->get(ITimeFactory::class),
$c->get(LangRepository::class),
$c->get(ICacheFactory::class)
);
});
@@ -83,10 +95,9 @@ class Application extends App implements IBootstrap
public function boot(IBootContext $context): void
{
$this->config = \OC::$server->getConfig();
$this->cspnm = \OC::$server->getContentSecurityPolicyNonceManager();
$this->user = \OC::$server[IUserSession::class]->getUser();
$this->request = \OC::$server->getRequest();
$this->config = Server::get(IConfig::class);
$this->user = Server::get(IUserSession::class)->getUser();
$this->request = Server::get(IRequest::class);
if (!$this->isEnabled()) {
return;
@@ -138,7 +149,7 @@ class Application extends App implements IBootstrap
$cache = $this->config->getAppValue(self::APP_ID, 'cache', '0');
foreach ($assets as $value) {
$route = \OC::$server->getURLGenerator()->linkToRoute(
$route = Server::get(IURLGenerator::class)->linkToRoute(
$value['route'],
['v' => $cache]
);
+83 -30
View File
@@ -20,12 +20,14 @@
namespace OCA\SideMenu\Controller;
use OCA\SideMenu\AppInfo\Application;
use OCA\SideMenu\Service\CategoryRepository;
use OCA\SideMenu\Service\Color;
use OCA\SideMenu\Service\ConfigProxy;
use OCA\SideMenu\Service\LangRepository;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired;
use OCP\AppFramework\Http\DataDownloadResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\RedirectResponse;
@@ -35,6 +37,12 @@ use OCP\IURLGenerator;
class AdminSettingController extends Controller
{
/**
* Keys that are managed by the app or by Nextcloud itself ("enabled")
* and must not be written from the settings form.
*/
protected const UNSAVED_KEYS = ['cache', 'cache-categories', 'enabled'];
public function __construct(
$appName,
IRequest $request,
@@ -43,6 +51,7 @@ class AdminSettingController extends Controller
protected IURLGenerator $urlGenerator,
protected Color $color,
protected LangRepository $langRepository,
protected CategoryRepository $categoryRepository,
) {
parent::__construct($appName, $request);
}
@@ -51,7 +60,7 @@ class AdminSettingController extends Controller
#[FrontpageRoute(verb: 'GET', url: '/admin/cache/remove')]
public function removeCache(): RedirectResponse
{
$this->config->setAppValue(Application::APP_ID, 'cache-categories', '[]');
$this->categoryRepository->clearCache();
return new RedirectResponse($this->urlGenerator->linkToRoute('settings.AdminSettings.index', [
'section' => Application::APP_ID,
@@ -97,6 +106,7 @@ class AdminSettingController extends Controller
'use-avatar',
'add-logo-link',
'show-settings',
'show-search-form',
'loader-enabled',
'always-displayed',
'enabled',
@@ -130,13 +140,84 @@ class AdminSettingController extends Controller
'top-menu-mouse-over-hidden-label',
];
$defaults = [
$defaults = $this->getDefaults();
$config = [];
foreach ($keys as $key) {
if (!isset($defaults[$key])) {
continue;
}
if (in_array($key, $booleans)) {
$config[$key] = $this->configProxy->getAppValueBool($key, $defaults[$key]);
} elseif (in_array($key, $arrays)) {
$config[$key] = $this->configProxy->getAppValueArray($key, $defaults[$key]);
} elseif (in_array($key, $integers)) {
$config[$key] = $this->configProxy->getAppValueInt($key, $defaults[$key]);
} else {
$config[$key] = $this->configProxy->getAppValue($key, $defaults[$key]);
}
}
foreach ($defaults as $key => $default) {
if (array_key_exists($key, $config)) {
continue;
}
if (in_array($key, $booleans)) {
$config[$key] = $default === '1';
} elseif (in_array($key, $arrays)) {
$config[$key] = json_decode($default);
} elseif (in_array($key, $integers)) {
$config[$key] = (int) $default;
} else {
$config[$key] = $default;
}
}
$config['langs'] = $this->langRepository->getUsedLangs();
return new JSONResponse($config);
}
#[PasswordConfirmationRequired]
#[FrontpageRoute(verb: 'POST', url: '/admin/config')]
public function saveConfiguration(array $config): JSONResponse
{
$allowed = array_diff(array_keys($this->getDefaults()), self::UNSAVED_KEYS);
foreach ($config as $key => $value) {
if (!in_array($key, $allowed, true)) {
continue;
}
if (is_array($value)) {
$value = json_encode($value);
} elseif (is_bool($value)) {
$value = $value ? '1' : '0';
}
$this->config->setAppValue(Application::APP_ID, $key, (string) $value);
}
// New stylesheet URL, and category names that match the new settings
$this->config->setAppValue(Application::APP_ID, 'cache', (string) time());
$this->categoryRepository->clearCache();
return new JSONResponse(['status' => 'ok']);
}
protected function getDefaults(): array
{
return [
'opener-only' => '0',
'opener-hover' => '0',
'display-logo' => '1',
'use-avatar' => '0',
'add-logo-link' => '1',
'show-settings' => '0',
'show-search-form' => '1',
'loader-enabled' => '1',
'always-displayed' => '0',
'enabled' => '1',
@@ -186,33 +267,5 @@ class AdminSettingController extends Controller
'categories-order-type' => 'default',
];
$config = [];
foreach ($keys as $key) {
if (!isset($defaults[$key])) {
continue;
}
if (in_array($key, $booleans)) {
$config[$key] = $this->configProxy->getAppValueBool($key, $defaults[$key]);
} elseif (in_array($key, $arrays)) {
$config[$key] = $this->configProxy->getAppValueArray($key, $defaults[$key]);
} elseif (in_array($key, $integers)) {
$config[$key] = $this->configProxy->getAppValueInt($key, $defaults[$key]);
} else {
$config[$key] = $this->configProxy->getAppValue($key, $defaults[$key]);
}
}
foreach ($defaults as $key => $default) {
if (!array_key_exists($key, $config)) {
$config[$key] = $default;
}
}
$config['langs'] = $this->langRepository->getUsedLangs();
return new JSONResponse($config);
}
}
+1
View File
@@ -135,6 +135,7 @@ class JsController extends Controller
'force-light-icon' => $this->config->getAppValueBool('force-light-icon', '0'),
'display-logo' => $this->config->getAppValueBool('display-logo', '1'),
'use-avatar' => $this->config->getAppValueBool('use-avatar', '0'),
'show-search-form' => $this->config->getAppValueBool('show-search-form', '1'),
'hide-when-no-apps' => $this->config->getAppValueBool('hide-when-no-apps', '0'),
'loader-enabled' => $this->config->getAppValueBool('loader-enabled', '1'),
'always-displayed' => $this->config->getAppValueBool('always-displayed', '0'),
+46
View File
@@ -0,0 +1,46 @@
<?php
/**
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace OCA\SideMenu\Migration;
use OCA\SideMenu\Service\CategoryRepository;
use OCP\Migration\IOutput;
use OCP\Migration\IRepairStep;
/**
* Earlier versions stored the category cache as a regular app config
* value, which Nextcloud loads on every request. Clear it on upgrade so
* it does not linger on instances that never request the categories.
*/
class ClearCategoryCache implements IRepairStep
{
public function __construct(protected CategoryRepository $categoryRepository)
{
}
public function getName(): string
{
return 'Clear the category cache';
}
public function run(IOutput $output): void
{
$this->categoryRepository->clearCache();
}
}
+2 -1
View File
@@ -40,7 +40,8 @@ class AppRepository
{
$navigation = $this->navigationManager->getAll();
$appCategoriesCustom = $this->config->getAppValueArray('apps-categories-custom', '[]');
$categories = $this->categoryRepository->getOrderedCategories();
// Only needed to check custom categories, skip loading them otherwise
$categories = empty(array_filter($appCategoriesCustom)) ? [] : $this->categoryRepository->getOrderedCategories();
$apps = $this->ocApp->listAllApps();
$visibleApps = [];
+122 -7
View File
@@ -4,6 +4,11 @@ namespace OCA\SideMenu\Service;
use OC\App\AppStore\Fetcher\CategoryFetcher;
use OCA\SideMenu\AppInfo\Application;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Exceptions\AppConfigTypeConflictException;
use OCP\IAppConfig;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\IUserSession;
use OCP\L10N\IFactory;
@@ -15,12 +20,23 @@ use OCP\L10N\IFactory;
*/
class CategoryRepository
{
public const CACHE_KEY = 'cache-categories';
/**
* The app store categories rarely change, refresh them once a week.
*/
protected const CACHE_TTL = 604800;
public function __construct(
protected CategoryFetcher $categoryFetcher,
protected ConfigProxy $config,
protected IConfig $iConfig,
protected IFactory $l10nFactory,
protected IUserSession $userSession,
protected IAppConfig $appConfig,
protected ITimeFactory $timeFactory,
protected LangRepository $langRepository,
protected ICacheFactory $cacheFactory,
) {
}
@@ -32,15 +48,9 @@ class CategoryRepository
$currentLanguage = substr($this->l10nFactory->findLanguage(), 0, 2);
$type = $this->config->getAppValue('categories-order-type', 'default');
$order = $this->config->getAppValueArray('categories-order', '[]');
$categoriesLabels = $this->config->getAppValueArray('cache-categories', '[]');
$categoriesLabels = $this->getCategories();
$customCategories = $this->config->getAppValueArray('categories-custom', '[]');
if (empty($categoriesLabels)) {
$categoriesLabels = $this->categoryFetcher->get();
$this->iConfig->setAppValue(Application::APP_ID, 'cache-categories', json_encode($categoriesLabels));
}
foreach ($categoriesLabels as $k => $category) {
$categoriesLabels[$category['id']] = $category['translations'][$currentLanguage]['name'] ??
$category['translations']['en']['name'];
@@ -85,4 +95,109 @@ class CategoryRepository
return $categoriesLabels;
}
public function clearCache(): void
{
$this->getDistributedCache()?->remove(self::CACHE_KEY);
$this->appConfig->deleteKey(Application::APP_ID, self::CACHE_KEY);
}
/**
* The languages to keep in the cache: English as fallback, the
* default language and the languages of the users, as two-letter
* codes like getOrderedCategories() looks them up. A user who picks
* a new language sees English names until the cache is refreshed,
* which saving the settings does right away.
*/
protected function getCachedLangs(): array
{
$langs = $this->langRepository->getUsedLangs();
$langs[] = (string) $this->iConfig->getSystemValue('default_language', 'en');
$langs[] = (string) $this->iConfig->getSystemValue('force_language', 'en');
return array_values(array_unique(array_filter(array_map(
static fn (string $lang) => strtolower(substr($lang, 0, 2)),
array_merge(['en'], $langs)
))));
}
/**
* Returns the app store categories with only their names. They are
* cached in the distributed cache (e.g. Redis) when one is configured,
* otherwise in a lazy app config value, so they are not loaded on
* every request with the other settings.
*/
protected function getCategories(): array
{
$cache = $this->getDistributedCache();
if ($cache !== null) {
$categories = $cache->get(self::CACHE_KEY);
if (is_array($categories)) {
return $categories;
}
} else {
try {
$stored = $this->appConfig->getValueArray(Application::APP_ID, self::CACHE_KEY, [], lazy: true);
} catch (AppConfigTypeConflictException) {
// Stored with another type, e.g. set by hand; refreshed below
$stored = [];
}
if (isset($stored['expires'], $stored['categories']) && $stored['expires'] > $this->timeFactory->getTime()) {
return $stored['categories'];
}
}
$categories = $this->fetchCategories();
// Don't cache an empty list, e.g. when the app store is unreachable
if (empty($categories)) {
return $categories;
}
$this->clearCache();
if ($cache !== null) {
$cache->set(self::CACHE_KEY, $categories, self::CACHE_TTL);
} else {
$this->appConfig->setValueArray(Application::APP_ID, self::CACHE_KEY, [
'expires' => $this->timeFactory->getTime() + self::CACHE_TTL,
'categories' => $categories,
], lazy: true);
}
return $categories;
}
protected function fetchCategories(): array
{
$langs = $this->getCachedLangs();
$categories = [];
foreach ($this->categoryFetcher->get() as $category) {
$names = [];
foreach ($category['translations'] ?? [] as $lang => $translation) {
if (in_array($lang, $langs, true)) {
$names[$lang] = ['name' => $translation['name'] ?? ''];
}
}
$categories[] = [
'id' => $category['id'],
'translations' => $names,
];
}
return $categories;
}
protected function getDistributedCache(): ?ICache
{
return $this->cacheFactory->isAvailable()
? $this->cacheFactory->createDistributed(Application::APP_ID)
: null;
}
}
+1609 -499
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -13,7 +13,7 @@
"@babel/core": ">=7.12.0 <8.0.0",
"@formatjs/intl-segmenter": "^12.0.8",
"@nextcloud/router": "^3.0.1",
"@nextcloud/vue": "^9.0.0-alpha.8",
"@nextcloud/vue": "^9.9.0",
"node-polyfill-webpack-plugin": "^4.1.0",
"pinia": "^3.0.1",
"postcss": "^7.0.0 || ^8.0.1",
+19 -40
View File
@@ -20,6 +20,9 @@
</template>
<script setup>
import axios from '@nextcloud/axios'
import { translate as t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import { NcButton, NcLoadingIcon } from '@nextcloud/vue'
import { ref } from 'vue'
import { waitPasswordConfirmation } from '../../lib/setting.js'
@@ -46,52 +49,28 @@ const filterConfig = (value) => {
}
const save = async () => {
const data = filterConfig(config)
const size = Object.keys(data).length
let counter = 0
loading.value = true
error.value = null
const update = () => {
++counter
if (counter === size) {
loading.value = false
if (!error.value) {
location.reload()
}
}
try {
await waitPasswordConfirmation()
} catch {
loading.value = false
return
}
waitPasswordConfirmation()
.then(() => {
for (let key in data) {
let value = data[key]
if (Array.isArray(value) || typeof value === 'object') {
value = JSON.stringify(value)
} else if (typeof value === 'boolean') {
value = value ? '1' : '0'
}
OCP.AppConfig.setValue('side_menu', key, value.toString(), {
success() {
update()
},
error() {
error.value = `Error while saving ${key}`
update()
},
})
}
})
.catch(() => {
counter = 0
loading.value = false
error.value = null
try {
// One request for all settings: separate requests per setting could
// leave a half-saved configuration in Nextcloud's config cache when
// the page reloads.
await axios.post(generateUrl('/apps/side_menu/admin/config'), {
config: filterConfig(config),
})
location.reload()
} catch {
error.value = t('side_menu', 'Error while saving the settings')
loading.value = false
}
}
</script>
+1
View File
@@ -109,3 +109,4 @@
'Customize': 'Přizpůsobit'
'Custom': 'Custom'
'Close': 'Zavřít'
'Show the search form': 'Show the search form'
+1
View File
@@ -109,3 +109,4 @@
'Customize': 'Customize'
'Custom': 'Custom'
'Close': 'Close'
'Show the search form': 'Show the search form'
+1
View File
@@ -109,3 +109,4 @@
'Customize': 'Personalizar'
'Custom': 'Custom'
'Close': 'Cerrar'
'Show the search form': 'Show the search form'
+1
View File
@@ -128,3 +128,4 @@
'Customize': 'Personnaliser'
'Custom': 'Personnalisé'
'Close': 'Fermer'
'Show the search form': 'Afficher le formulaire de recherche'
+1
View File
@@ -109,3 +109,4 @@
'Customize': 'Personalizar'
'Custom': 'Personalizado'
'Close': 'Pechar'
'Show the search form': 'Show the search form'
+1
View File
@@ -109,3 +109,4 @@
'Customize': 'Customize'
'Custom': 'Custom'
'Close': 'Close'
'Show the search form': 'Show the search form'
+1
View File
@@ -107,3 +107,4 @@
'Customize': 'Customize'
'Custom': 'Custom'
'Close': 'Close'
'Show the search form': 'Show the search form'
+1
View File
@@ -109,3 +109,4 @@
'Customize': 'Приспособить'
'Custom': 'Custom'
'Close': 'Закрыть'
'Show the search form': 'Show the search form'
+1
View File
@@ -107,3 +107,4 @@
'Customize': 'Customize'
'Custom': 'Custom'
'Close': 'Close'
'Show the search form': 'Show the search form'
+1
View File
@@ -109,3 +109,4 @@
'Customize': 'Anpassa'
'Custom': 'Anpassad'
'Close': 'Stäng'
'Show the search form': 'Show the search form'
+1
View File
@@ -109,3 +109,4 @@
'Customize': 'Customize'
'Custom': 'Custom'
'Close': 'Close'
'Show the search form': 'Show the search form'
+1
View File
@@ -109,3 +109,4 @@
'Customize': '自定义'
'Custom': '自定义'
'Close': '关闭'
'Show the search form': 'Show the search form'
+3 -1
View File
@@ -27,7 +27,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:label="settings.name"
:avatar="settings.avatar"
/>
<AppSearch v-model="search" />
<AppSearch v-if="showSearchForm" v-model="search" />
<OpenerButton
v-if="!openerHover"
@click="$emit('close')"
@@ -102,6 +102,7 @@ const activeApp = ref(null)
const targetBlankApps = ref([])
const settings = ref(null)
const search = ref('')
const showSearchForm = ref(true)
const openerHover = ref(false)
const menu = useTemplateRef('menu')
const isTouchDevice = window.matchMedia('(pointer: coarse)').matches
@@ -120,6 +121,7 @@ onMounted(async () => {
targetBlankApps.value = config['target-blank-apps']
settings.value = config['settings']
showSearchForm.value = config['show-search-form']
openerHover.value = config['opener-hover'] && !isTouchDevice
items.value = await navStore.getCategories()
+3 -1
View File
@@ -30,7 +30,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:avatar="settings.avatar"
/>
<AppSearch
v-if="open"
v-if="showSearchForm && open"
v-model="search"
/>
<OpenerButton
@@ -103,6 +103,7 @@ const openerHover = ref(false)
const alwaysDisplayed = ref(false)
const displayLogo = ref(false)
const useAvatarAsLogo = ref(false)
const showSearchForm = ref(true)
const search = ref('')
const apps = ref([])
const menu = useTemplateRef('menu')
@@ -151,6 +152,7 @@ onMounted(async () => {
alwaysDisplayed.value = config['always-displayed']
targetBlankApps.value = config['target-blank-apps']
forceLightIcon.value = config['force-light-icon']
showSearchForm.value = config['show-search-form']
avatar.value = config['avatar']
logo.value = config['logo']
+17 -12
View File
@@ -62,8 +62,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</li>
</ul>
<NcActions
class="cm-standardmenu-app-menu-more app-menu-more"
class="cm-standardmenu-app-menu-more"
:aria-label="t('core', 'More apps')"
:forceMenu="true"
>
<NcActionLink
v-for="app in popoverAppList"
@@ -132,19 +133,27 @@ const setApps = (value) => {
const appLimit = () => {
const headerStart = document.querySelector('#header .header-start')
const headerEnd = document.querySelector('#header .header-end')
const unifiedSearch = document.querySelector('#header .unified-search-input')
const body = document.querySelector('body')
let size = (headerEnd ? headerEnd.offsetWidth : 0) + 70
let totalWidth = body.offsetWidth
let otherElementsWidth = headerEnd ? headerEnd.offsetWidth : 0
if (unifiedSearch && headerEnd.offsetWidth < unifiedSearch.offsetWidth) {
otherElementsWidth += unifiedSearch.offsetWidth
}
let maxWidth = body.offsetWidth - otherElementsWidth
if (headerStart) {
Array.from(headerStart.children).forEach((child) => {
if (child.id !== 'app-menu-container') {
size += child.offsetWidth
maxWidth -= child.offsetWidth
}
})
}
return Math.max(0, Math.floor((body.offsetWidth - size) / 70))
return Math.max(0, Math.floor(maxWidth / 60))
}
const makeStyle = (app) => {
@@ -156,13 +165,9 @@ const makeStyle = (app) => {
}
const computeLists = () => {
mainAppList.value = appList.value.slice(0, appLimit())
popoverAppList.value = appList.value.slice(appLimit()).sort((a, b) => a.order - b.order)
}
const reComputeLists = (delay) => {
window.clearTimeout(resizeTimeout)
resizeTimeout = window.setTimeout(computeLists, delay || 100)
const limit = appLimit()
mainAppList.value = appList.value.slice(0, limit)
popoverAppList.value = appList.value.slice(limit).sort((a, b) => a.order - b.order)
}
onMounted(async () => {
@@ -177,7 +182,7 @@ onMounted(async () => {
setApps(await navStore.getCoreApps())
window.addEventListener('resize', reComputeLists)
window.setInterval(computeLists, 100)
})
</script>
+3 -1
View File
@@ -31,7 +31,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:label="settings.name"
:avatar="settings.avatar"
/>
<AppSearch v-model="search" />
<AppSearch v-if="showSearchForm" v-model="search" />
<OpenerButton
v-if="!openerHover"
@click="$emit('close')"
@@ -107,6 +107,7 @@ const activeApp = ref(null)
const targetBlankApps = ref([])
const settings = ref(null)
const search = ref('')
const showSearchForm = ref(true)
const openerHover = ref(false)
const menu = useTemplateRef('menu')
const isTouchDevice = window.matchMedia('(pointer: coarse)').matches
@@ -125,6 +126,7 @@ onMounted(async () => {
targetBlankApps.value = config['target-blank-apps']
settings.value = config['settings']
showSearchForm.value = config['show-search-form']
openerHover.value = config['opener-hover'] && !isTouchDevice
items.value = await navStore.getCategories()
+15
View File
@@ -29,6 +29,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
</NcAppNavigation>
<NcAppContent class="cm-settings cm-settings--nav">
<button @click="toggleNav" class="cm-settings-nav-toggle">{{ trans('Toggle menu') }}</button>
<SettingsSection :hidden="section !== 'panel'">
<SectionTitle label="Panel" />
@@ -80,6 +82,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<FormYesNo v-model="config['show-settings']" />
</SettingValue>
</SettingItem>
<SettingItem :disabled="config['always-displayed']">
<SettingLabel
label="Show the search form"
:middle="true"
/>
<SettingValue>
<FormYesNo v-model="config['show-search-form']" />
</SettingValue>
</SettingItem>
<SettingItem>
<SettingLabel
label="Icons"
@@ -619,6 +630,10 @@ const filterConfig = (value) => {
return result
}
const toggleNav = () => {
document.querySelector('.cm-settings-nav .app-navigation-toggle-wrapper > button').click()
}
onMounted(async () => {
config.value = await configStore.getAppConfig()
+16 -5
View File
@@ -15,14 +15,25 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
.cm-settings {
&--nav {
padding-top: 30px;
}
.cm-settings-nav {
padding-top: 30px !important;
}
.cm-settings-nav-toggle {
margin-top: 40px !important;
margin-left: 15px !important;
}
.cm-settings-nav .app-navigation-toggle-wrapper > button {
display: none;
}
.cm-settings {
&-nav {
max-width: 50vw !important;
.app-navigation__content {
padding: 20px;
padding: 20px 10px 20px 10px;
}
.app-navigation-entry-icon {
+58 -22
View File
@@ -287,14 +287,6 @@
}
}
.cm-standardmenu {
visibility: hidden;
&.show {
visibility: visible;
}
}
.cm-always-displayed {
body {
width: calc(100% - 50px) !important;
@@ -363,6 +355,16 @@
right: 0 !important;
margin-left: 0 !important;
}
#content {
left: 53px;
width: calc(100% - (var(--body-container-margin) * 2) - 62px);
}
#content-vue {
width: calc(100% - (var(--body-container-margin) * 2) - 60px);
margin-left: 11px;
}
}
@media screen and (max-width: 1024px) {
@@ -385,6 +387,16 @@
padding: 10px 0;
}
}
#header {
.unified-search-input {
width: 80px !important;
.unified-search-input__label {
display: none;
}
}
}
}
@media screen and (min-width: 1024px) {
@@ -406,11 +418,25 @@
$header-icon-size: 20px;
.cm-standardmenu {
visibility: hidden;
width: 100%;
display: flex;
flex-shrink: 1;
flex-wrap: wrap;
&.show {
visibility: visible;
}
svg {
color: var(--color-background-plain-text) !important;
}
.app-menu-entry--label {
color: var(--color-background-plain-text) !important;
filter: var(--background-image-invert-if-bright, var(--primary-invert-if-bright)) !important;
}
.app-menu-main {
display: flex;
flex-wrap: nowrap;
@@ -470,7 +496,7 @@ $header-icon-size: 20px;
color: var(--color-primary-text);
text-align: center;
left: 50%;
top: 45%;
top: 55%;
display: block;
min-width: 100%;
transform: translateX(-50%);
@@ -542,23 +568,27 @@ $header-icon-size: 20px;
}
}
.app-menu-more .button-vue--vue-tertiary {
opacity: 0.7;
margin: 8px 3px 3px 3px;
filter: var(--background-image-invert-if-bright, var(--primary-invert-if-bright));
&-app-menu-more {
padding-top: 9px !important;
&:not([aria-expanded='true']) {
color: var(--color-main-text);
.button-vue--vue-tertiary {
opacity: 0.7;
margin: 8px 3px 3px 3px;
filter: var(--background-image-invert-if-bright, var(--primary-invert-if-bright));
&:hover {
opacity: 1;
background-color: transparent !important;
&:not([aria-expanded='true']) {
color: var(--color-main-text);
&:hover {
opacity: 1;
background-color: transparent !important;
}
}
}
&:focus-visible {
opacity: 1;
outline: none !important;
&:focus-visible {
opacity: 1;
outline: none !important;
}
}
}
@@ -599,3 +629,9 @@ $header-icon-size: 20px;
display: none;
}
}
#header {
.unified-search-input:not(.unified-search-input--mobile) {
position: unset !important;
}
}
-12
View File
@@ -117,15 +117,3 @@ body[data-theme-light], body[data-theme-light-highcontrast] {
font-size: 16px;
}
<?php } ?>
<?php if ($_['always-displayed']) { ?>
#content {
left: 53px;
width: calc(100% - (var(--body-container-margin) * 2) - 62px);
}
#content-vue {
width: calc(100% - (var(--body-container-margin) * 2) - 60px);
margin-left: 11px;
}
<?php } ?>
+2
View File
@@ -7,6 +7,7 @@ const NodePolyfillPlugin = require('node-polyfill-webpack-plugin')
const TerserPlugin = require('terser-webpack-plugin')
const appName = 'side_menu'
const appVersion = '6.x'
const buildMode = process.env.NODE_ENV
const isDev = buildMode === 'development'
@@ -80,6 +81,7 @@ module.exports = {
new webpack.DefinePlugin({
appName: JSON.stringify(appName),
appVersion: JSON.stringify(appVersion),
}),
],