Browse Source

Merge pull request #42977 from nextcloud/theming/create-color-from-background

theming: Separate `primary` and `background` colors - fix the header menu colors
fix/files-proper-loading-icon
Ferdinand Thiessen 3 weeks ago
parent
commit
576e249476
No account linked to committer's email address
76 changed files with 1473 additions and 799 deletions
  1. 2
    2
      apps/dashboard/src/DashboardApp.vue
  2. 3
    3
      apps/settings/src/components/AdminAI.vue
  3. 2
    2
      apps/theming/css/default.css
  4. 8
    11
      apps/theming/lib/Capabilities.php
  5. 7
    2
      apps/theming/lib/Command/UpdateConfig.php
  6. 6
    9
      apps/theming/lib/Controller/ThemingController.php
  7. 4
    4
      apps/theming/lib/Controller/UserThemeController.php
  8. 49
    34
      apps/theming/lib/ImageManager.php
  9. 13
    3
      apps/theming/lib/Listener/BeforePreferenceListener.php
  10. 0
    38
      apps/theming/lib/Listener/BeforeTemplateRenderedListener.php
  11. 1
    0
      apps/theming/lib/ResponseDefinitions.php
  12. 158
    41
      apps/theming/lib/Service/BackgroundService.php
  13. 15
    13
      apps/theming/lib/Service/JSDataService.php
  14. 6
    1
      apps/theming/lib/Settings/Admin.php
  15. 21
    0
      apps/theming/lib/Settings/Personal.php
  16. 34
    44
      apps/theming/lib/Themes/CommonThemeTrait.php
  17. 0
    6
      apps/theming/lib/Themes/DefaultTheme.php
  18. 87
    58
      apps/theming/lib/ThemingDefaults.php
  19. 1
    1
      apps/theming/lib/Util.php
  20. 4
    0
      apps/theming/openapi.json
  21. 117
    60
      apps/theming/src/AdminTheming.vue
  22. 28
    27
      apps/theming/src/UserTheming.vue
  23. 0
    2
      apps/theming/src/admin-settings.js
  24. 56
    59
      apps/theming/src/components/BackgroundSettings.vue
  25. 156
    0
      apps/theming/src/components/UserPrimaryColor.vue
  26. 59
    8
      apps/theming/src/components/admin/ColorPickerField.vue
  27. 3
    2
      apps/theming/src/components/admin/FileInputField.vue
  28. 15
    5
      apps/theming/src/helpers/refreshStyles.js
  29. 6
    2
      apps/theming/src/mixins/admin/TextValueMixin.js
  30. 1
    1
      apps/theming/src/personal-settings.js
  31. 28
    11
      apps/theming/tests/CapabilitiesTest.php
  32. 7
    14
      apps/theming/tests/Controller/ThemingControllerTest.php
  33. 6
    1
      apps/theming/tests/ImageManagerTest.php
  34. 10
    4
      apps/theming/tests/Settings/PersonalTest.php
  35. 8
    0
      apps/theming/tests/Themes/DarkHighContrastThemeTest.php
  36. 8
    0
      apps/theming/tests/Themes/DarkThemeTest.php
  37. 14
    2
      apps/theming/tests/Themes/DefaultThemeTest.php
  38. 10
    0
      apps/theming/tests/Themes/DyslexiaFontTest.php
  39. 8
    0
      apps/theming/tests/Themes/HighContrastThemeTest.php
  40. 65
    101
      apps/theming/tests/ThemingDefaultsTest.php
  41. 1
    1
      core/css/apps.css
  42. 1
    1
      core/css/apps.css.map
  43. 4
    4
      core/css/apps.scss
  44. 1
    1
      core/css/guest.css
  45. 1
    1
      core/css/guest.css.map
  46. 7
    10
      core/css/guest.scss
  47. 1
    1
      core/css/header.css
  48. 1
    1
      core/css/header.css.map
  49. 3
    11
      core/css/header.scss
  50. 1
    1
      core/css/server.css
  51. 1
    1
      core/css/server.css.map
  52. 7
    7
      core/src/components/AppMenu.vue
  53. 5
    1
      core/src/views/ContactsMenu.vue
  54. 5
    2
      core/src/views/LegacyUnifiedSearch.vue
  55. 1
    1
      core/src/views/UnifiedSearch.vue
  56. 236
    100
      cypress/e2e/theming/admin-settings.cy.ts
  57. 48
    22
      cypress/e2e/theming/themingUtils.ts
  58. 38
    29
      cypress/e2e/theming/user-background.cy.ts
  59. 3
    3
      dist/core-legacy-unified-search.js
  60. 1
    1
      dist/core-legacy-unified-search.js.map
  61. 3
    3
      dist/core-main.js
  62. 1
    1
      dist/core-main.js.map
  63. 3
    3
      dist/core-unified-search.js
  64. 1
    1
      dist/core-unified-search.js.map
  65. 3
    3
      dist/dashboard-main.js
  66. 1
    1
      dist/dashboard-main.js.map
  67. 3
    3
      dist/settings-vue-settings-admin-ai.js
  68. 1
    1
      dist/settings-vue-settings-admin-ai.js.map
  69. 3
    3
      dist/theming-admin-theming.js
  70. 1
    1
      dist/theming-admin-theming.js.map
  71. 3
    3
      dist/theming-personal-theming.js
  72. 22
    0
      dist/theming-personal-theming.js.license
  73. 1
    1
      dist/theming-personal-theming.js.map
  74. 13
    4
      lib/private/Server.php
  75. 14
    1
      lib/private/legacy/OC_Defaults.php
  76. 8
    0
      themes/example/defaults.php

+ 2
- 2
apps/dashboard/src/DashboardApp.vue View File

@@ -471,8 +471,8 @@ export default {
background-attachment: fixed;

> h2 {
// this is shown directly on the background which has `color-primary`, so we need `color-primary-text`
color: var(--color-primary-text);
// this is shown directly on the background image / color
color: var(--color-background-plain-text);
text-align: center;
font-size: 32px;
line-height: 130%;

+ 3
- 3
apps/settings/src/components/AdminAI.vue View File

@@ -190,9 +190,9 @@ export default {

.draggable__number {
border-radius: 20px;
border: 2px solid var(--color-primary-default);
color: var(--color-primary-default);
padding: 0px 7px;
border: 2px solid var(--color-primary-element);
color: var(--color-primary-element);
padding: 0px 7px;
margin-right: 3px;
}


+ 2
- 2
apps/theming/css/default.css View File

@@ -71,7 +71,6 @@
--primary-invert-if-bright: no;
--primary-invert-if-dark: invert(100%);
--color-primary: #00679e;
--color-primary-default: #0082c9;
--color-primary-text: #ffffff;
--color-primary-hover: #3285b1;
--color-primary-light: #e5eff5;
@@ -85,6 +84,7 @@
--color-primary-element-light-hover: #dbe4ea;
--color-primary-element-light-text: #00293f;
--gradient-primary-background: linear-gradient(40deg, var(--color-primary) 0%, var(--color-primary-hover) 100%);
--image-background-default: url('/apps/theming/img/background/kamil-porembinski-clouds.jpg');
--color-background-plain: #00679e;
--color-background-plain-text: #ffffff;
--image-background: url('/apps/theming/img/background/kamil-porembinski-clouds.jpg');
}

+ 8
- 11
apps/theming/lib/Capabilities.php View File

@@ -85,6 +85,7 @@ class Capabilities implements IPublicCapability {
* color-element-dark: string,
* logo: string,
* background: string,
* background-text: string,
* background-plain: bool,
* background-default: bool,
* logoheader: string,
@@ -94,15 +95,13 @@ class Capabilities implements IPublicCapability {
*/
public function getCapabilities() {
$color = $this->theming->getDefaultColorPrimary();
// Same as in DefaultTheme
if ($color === BackgroundService::DEFAULT_COLOR) {
$color = BackgroundService::DEFAULT_ACCESSIBLE_COLOR;
}
$colorText = $this->util->invertTextColor($color) ? '#000000' : '#ffffff';

$backgroundLogo = $this->config->getAppValue('theming', 'backgroundMime', '');
$backgroundPlain = $backgroundLogo === 'backgroundColor' || ($backgroundLogo === '' && $color !== '#0082c9');
$background = $backgroundPlain ? $color : $this->url->getAbsoluteURL($this->theming->getBackground());
$backgroundColor = $this->theming->getColorBackground();
$backgroundText = $this->theming->getTextColorBackground();
$backgroundPlain = $backgroundLogo === 'backgroundColor' || ($backgroundLogo === '' && $backgroundColor !== BackgroundService::DEFAULT_COLOR);
$background = $backgroundPlain ? $backgroundColor : $this->url->getAbsoluteURL($this->theming->getBackground());

$user = $this->userSession->getUser();
if ($user instanceof IUser) {
@@ -112,10 +111,7 @@ class Capabilities implements IPublicCapability {
* @see \OCA\Theming\Themes\CommonThemeTrait::generateUserBackgroundVariables()
*/
$color = $this->theming->getColorPrimary();
if ($color === BackgroundService::DEFAULT_COLOR) {
$color = BackgroundService::DEFAULT_ACCESSIBLE_COLOR;
}
$colorText = $this->util->invertTextColor($color) ? '#000000' : '#ffffff';
$colorText = $this->theming->getTextColorPrimary();

$backgroundImage = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'background_image', BackgroundService::BACKGROUND_DEFAULT);
if ($backgroundImage === BackgroundService::BACKGROUND_CUSTOM) {
@@ -126,7 +122,7 @@ class Capabilities implements IPublicCapability {
$background = $this->url->linkTo(Application::APP_ID, "img/background/$backgroundImage");
} elseif ($backgroundImage !== BackgroundService::BACKGROUND_DEFAULT) {
$backgroundPlain = true;
$background = $color;
$background = $backgroundColor;
}
}

@@ -142,6 +138,7 @@ class Capabilities implements IPublicCapability {
'color-element-dark' => $this->util->elementColor($color, false),
'logo' => $this->url->getAbsoluteURL($this->theming->getLogo()),
'background' => $background,
'background-text' => $backgroundText,
'background-plain' => $backgroundPlain,
'background-default' => !$this->util->isBackgroundThemed(),
'logoheader' => $this->url->getAbsoluteURL($this->theming->getLogo()),

+ 7
- 2
apps/theming/lib/Command/UpdateConfig.php View File

@@ -33,7 +33,7 @@ use Symfony\Component\Console\Output\OutputInterface;

class UpdateConfig extends Command {
public const SUPPORTED_KEYS = [
'name', 'url', 'imprintUrl', 'privacyUrl', 'slogan', 'color', 'disable-user-theming'
'name', 'url', 'imprintUrl', 'privacyUrl', 'slogan', 'color', 'primary_color', 'disable-user-theming'
];

private $themingDefaults;
@@ -128,8 +128,13 @@ class UpdateConfig extends Command {
$value = $this->imageManager->updateImage($key, $value);
$key = $key . 'Mime';
}
if ($key === 'color') {
$output->writeln('<warning>Using "color" is depreacted, use "primary_color" instead');
$key = 'primary_color';
}

if ($key === 'color' && !preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $value)) {
if ($key === 'primary_color' && !preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $value)) {
$output->writeln('<error>The given color is invalid: ' . $value . '</error>');
return 1;
}

+ 6
- 9
apps/theming/lib/Controller/ThemingController.php View File

@@ -50,13 +50,11 @@ use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\FileDisplayResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\NotFoundResponse;
use OCP\Files\IAppData;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IConfig;
use OCP\IL10N;
use OCP\IRequest;
use OCP\ITempManager;
use OCP\IURLGenerator;
use ScssPhp\ScssPhp\Compiler;

@@ -73,8 +71,6 @@ class ThemingController extends Controller {
private ThemingDefaults $themingDefaults;
private IL10N $l10n;
private IConfig $config;
private ITempManager $tempManager;
private IAppData $appData;
private IURLGenerator $urlGenerator;
private IAppManager $appManager;
private ImageManager $imageManager;
@@ -86,8 +82,6 @@ class ThemingController extends Controller {
IConfig $config,
ThemingDefaults $themingDefaults,
IL10N $l,
ITempManager $tempManager,
IAppData $appData,
IURLGenerator $urlGenerator,
IAppManager $appManager,
ImageManager $imageManager,
@@ -98,8 +92,6 @@ class ThemingController extends Controller {
$this->themingDefaults = $themingDefaults;
$this->l10n = $l;
$this->config = $config;
$this->tempManager = $tempManager;
$this->appData = $appData;
$this->urlGenerator = $urlGenerator;
$this->appManager = $appManager;
$this->imageManager = $imageManager;
@@ -151,7 +143,12 @@ class ThemingController extends Controller {
$error = $this->l10n->t('The given slogan is too long');
}
break;
case 'color':
case 'primary_color':
if (!preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $value)) {
$error = $this->l10n->t('The given color is invalid');
}
break;
case 'background_color':
if (!preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $value)) {
$error = $this->l10n->t('The given color is invalid');
}

+ 4
- 4
apps/theming/lib/Controller/UserThemeController.php View File

@@ -58,7 +58,6 @@ class UserThemeController extends OCSController {
protected ?string $userId = null;

private IConfig $config;
private IUserSession $userSession;
private ThemesService $themesService;
private ThemingDefaults $themingDefaults;
private BackgroundService $backgroundService;
@@ -72,7 +71,6 @@ class UserThemeController extends OCSController {
BackgroundService $backgroundService) {
parent::__construct($appName, $request);
$this->config = $config;
$this->userSession = $userSession;
$this->themesService = $themesService;
$this->themingDefaults = $themingDefaults;
$this->backgroundService = $backgroundService;
@@ -186,7 +184,8 @@ class UserThemeController extends OCSController {
$this->backgroundService->deleteBackgroundImage();
return new JSONResponse([
'backgroundImage' => null,
'backgroundColor' => $this->themingDefaults->getColorPrimary(),
'backgroundColor' => $this->themingDefaults->getColorBackground(),
'primaryColor' => $this->themingDefaults->getColorPrimary(),
'version' => $currentVersion,
]);
}
@@ -241,7 +240,8 @@ class UserThemeController extends OCSController {

return new JSONResponse([
'backgroundImage' => $this->config->getUserValue($this->userId, Application::APP_ID, 'background_image', BackgroundService::BACKGROUND_DEFAULT),
'backgroundColor' => $this->themingDefaults->getColorPrimary(),
'backgroundColor' => $this->themingDefaults->getColorBackground(),
'primaryColor' => $this->themingDefaults->getColorPrimary(),
'version' => $currentVersion,
]);
}

+ 49
- 34
apps/theming/lib/ImageManager.php View File

@@ -4,6 +4,7 @@
*
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
* @author Daniel Kesselberg <mail@danielkesselberg.de>
* @author Ferdinand Thiessen <opensource@fthiessen.de>
* @author Gary Kim <gary@garykim.dev>
* @author Jacob Neplokh <me@jacobneplokh.com>
* @author John Molakvoæ <skjnldsv@protonmail.com>
@@ -56,6 +57,7 @@ class ImageManager {
private ICacheFactory $cacheFactory,
private LoggerInterface $logger,
private ITempManager $tempManager,
private BackgroundService $backgroundService,
) {
}

@@ -77,7 +79,11 @@ class ImageManager {
case 'favicon':
return $this->urlGenerator->imagePath('core', 'logo/logo.png') . '?v=' . $cacheBusterCounter;
case 'background':
return $this->urlGenerator->linkTo(Application::APP_ID, 'img/background/' . BackgroundService::DEFAULT_BACKGROUND_IMAGE);
// Removing the background defines its mime as 'backgroundColor'
$mimeSetting = $this->config->getAppValue('theming', 'backgroundMime', '');
if ($mimeSetting !== 'backgroundColor') {
return $this->urlGenerator->linkTo(Application::APP_ID, 'img/background/' . BackgroundService::DEFAULT_BACKGROUND_IMAGE);
}
}
return '';
}
@@ -227,47 +233,56 @@ class ImageManager {
throw new \Exception('Unsupported image type: ' . $detectedMimeType);
}

if ($key === 'background' && $this->shouldOptimizeBackgroundImage($detectedMimeType, filesize($tmpFile))) {
try {
// Optimize the image since some people may upload images that will be
// either to big or are not progressive rendering.
$newImage = @imagecreatefromstring(file_get_contents($tmpFile));
if ($newImage === false) {
throw new \Exception('Could not read background image, possibly corrupted.');
}
if ($key === 'background') {
if ($this->shouldOptimizeBackgroundImage($detectedMimeType, filesize($tmpFile))) {
try {
// Optimize the image since some people may upload images that will be
// either to big or are not progressive rendering.
$newImage = @imagecreatefromstring(file_get_contents($tmpFile));
if ($newImage === false) {
throw new \Exception('Could not read background image, possibly corrupted.');
}

// Preserve transparency
imagesavealpha($newImage, true);
imagealphablending($newImage, true);
// Preserve transparency
imagesavealpha($newImage, true);
imagealphablending($newImage, true);

$newWidth = (imagesx($newImage) < 4096 ? imagesx($newImage) : 4096);
$newHeight = (int)(imagesy($newImage) / (imagesx($newImage) / $newWidth));
$outputImage = imagescale($newImage, $newWidth, $newHeight);
if ($outputImage === false) {
throw new \Exception('Could not scale uploaded background image.');
}
$imageWidth = imagesx($newImage);
$imageHeight = imagesy($newImage);

$newTmpFile = $this->tempManager->getTemporaryFile();
imageinterlace($outputImage, true);
// Keep jpeg images encoded as jpeg
if (str_contains($detectedMimeType, 'image/jpeg')) {
if (!imagejpeg($outputImage, $newTmpFile, 90)) {
throw new \Exception('Could not recompress background image as JPEG');
/** @var int */
$newWidth = min(4096, $imageWidth);
$newHeight = intval($imageHeight / ($imageWidth / $newWidth));
$outputImage = imagescale($newImage, $newWidth, $newHeight);
if ($outputImage === false) {
throw new \Exception('Could not scale uploaded background image.');
}
} else {
if (!imagepng($outputImage, $newTmpFile, 8)) {
throw new \Exception('Could not recompress background image as PNG');

$newTmpFile = $this->tempManager->getTemporaryFile();
imageinterlace($outputImage, true);
// Keep jpeg images encoded as jpeg
if (str_contains($detectedMimeType, 'image/jpeg')) {
if (!imagejpeg($outputImage, $newTmpFile, 90)) {
throw new \Exception('Could not recompress background image as JPEG');
}
} else {
if (!imagepng($outputImage, $newTmpFile, 8)) {
throw new \Exception('Could not recompress background image as PNG');
}
}
}
$tmpFile = $newTmpFile;
imagedestroy($outputImage);
} catch (\Exception $e) {
if (is_resource($outputImage) || $outputImage instanceof \GdImage) {
$tmpFile = $newTmpFile;
imagedestroy($outputImage);
}
} catch (\Exception $e) {
if (isset($outputImage) && is_resource($outputImage) || $outputImage instanceof \GdImage) {
imagedestroy($outputImage);
}

$this->logger->debug($e->getMessage());
$this->logger->debug($e->getMessage());
}
}

// For background images we need to announce it
$this->backgroundService->setGlobalBackground($tmpFile);
}

$target->putContent(file_get_contents($tmpFile));

+ 13
- 3
apps/theming/lib/Listener/BeforePreferenceListener.php View File

@@ -55,14 +55,24 @@ class BeforePreferenceListener implements IEventListener {
}

private function handleThemingValues(BeforePreferenceSetEvent|BeforePreferenceDeletedEvent $event): void {
if ($event->getConfigKey() !== 'shortcuts_disabled') {
$allowedKeys = ['shortcuts_disabled', 'primary_color'];

if (!in_array($event->getConfigKey(), $allowedKeys)) {
// Not allowed config key
return;
}

if ($event instanceof BeforePreferenceSetEvent) {
$event->setValid($event->getConfigValue() === 'yes');
return;
switch ($event->getConfigKey()) {
case 'shortcuts_disabled':
$event->setValid($event->getConfigValue() === 'yes');
break;
case 'primary_color':
$event->setValid(preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $event->getConfigValue()) === 1);
break;
default:
$event->setValid(false);
}
}

$event->setValid(true);

+ 0
- 38
apps/theming/lib/Listener/BeforeTemplateRenderedListener.php View File

@@ -26,7 +26,6 @@ declare(strict_types=1);
namespace OCA\Theming\Listener;

use OCA\Theming\AppInfo\Application;
use OCA\Theming\Service\BackgroundService;
use OCA\Theming\Service\JSDataService;
use OCA\Theming\Service\ThemeInjectionService;
use OCP\AppFramework\Http\Events\BeforeLoginTemplateRenderedEvent;
@@ -81,43 +80,6 @@ class BeforeTemplateRenderedListener implements IEventListener {

$this->themeInjectionService->injectHeaders();

$user = $this->userSession->getUser();

if (!empty($user)) {
$userId = $user->getUID();

/** User background */
$this->initialState->provideInitialState(
'backgroundImage',
$this->config->getUserValue($userId, Application::APP_ID, 'background_image', BackgroundService::BACKGROUND_DEFAULT),
);

/** User color */
$this->initialState->provideInitialState(
'backgroundColor',
$this->config->getUserValue($userId, Application::APP_ID, 'background_color', BackgroundService::DEFAULT_COLOR),
);

/**
* Admin background. `backgroundColor` if disabled,
* mime type if defined and empty by default
*/
$this->initialState->provideInitialState(
'themingDefaultBackground',
$this->config->getAppValue('theming', 'backgroundMime', ''),
);
$this->initialState->provideInitialState(
'defaultShippedBackground',
BackgroundService::DEFAULT_BACKGROUND_IMAGE,
);

/** List of all shipped backgrounds */
$this->initialState->provideInitialState(
'shippedBackgrounds',
BackgroundService::SHIPPED_BACKGROUNDS,
);
}

// Making sure to inject just after core
\OCP\Util::addScript('theming', 'theming', 'core');
}

+ 1
- 0
apps/theming/lib/ResponseDefinitions.php View File

@@ -30,6 +30,7 @@ namespace OCA\Theming;
* @psalm-type ThemingBackground = array{
* backgroundImage: ?string,
* backgroundColor: string,
* primaryColor: string,
* version: int,
* }
*/

+ 158
- 41
apps/theming/lib/Service/BackgroundService.php View File

@@ -8,6 +8,7 @@ declare(strict_types=1);
* @author Jan C. Borchardt <hey@jancborchardt.net>
* @author Julius Härtl <jus@bitgrid.net>
* @author Christopher Ng <chrng8@gmail.com>
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license GNU AGPL version 3 or any later version
*
@@ -30,7 +31,6 @@ namespace OCA\Theming\Service;
use InvalidArgumentException;
use OC\User\NoUserException;
use OCA\Theming\AppInfo\Application;
use OCA\Theming\ThemingDefaults;
use OCP\Files\File;
use OCP\Files\IAppData;
use OCP\Files\IRootFolder;
@@ -41,167 +41,186 @@ use OCP\Files\SimpleFS\ISimpleFolder;
use OCP\IConfig;
use OCP\Lock\LockedException;
use OCP\PreConditionNotMetException;
use RuntimeException;

class BackgroundService {
// true when the background is bright and need dark icons
public const THEMING_MODE_DARK = 'dark';
public const DEFAULT_COLOR = '#0082c9';
public const DEFAULT_ACCESSIBLE_COLOR = '#00679e';
public const DEFAULT_COLOR = '#00679e';
public const DEFAULT_BACKGROUND_COLOR = '#00679e';

/**
* One of our shipped background images is used
*/
public const BACKGROUND_SHIPPED = 'shipped';
/**
* A custom background image is used
*/
public const BACKGROUND_CUSTOM = 'custom';
/**
* The default background image is used
*/
public const BACKGROUND_DEFAULT = 'default';
public const BACKGROUND_DISABLED = 'disabled';
/**
* Just a background color is used
*/
public const BACKGROUND_COLOR = 'color';

public const DEFAULT_BACKGROUND_IMAGE = 'kamil-porembinski-clouds.jpg';

/**
* 'attribution': Name, artist and license
* 'description': Alternative text
* 'attribution_url': URL for attribution
* 'background_color': Cached mean color of the top part to calculate app menu colors and use as fallback
* 'primary_color': Recommended primary color for this theme / image
*/
public const SHIPPED_BACKGROUNDS = [
'hannah-maclean-soft-floral.jpg' => [
'attribution' => 'Soft floral (Hannah MacLean, CC0)',
'description' => 'Abstract background picture in yellow and white color whith a flower on it',
'attribution_url' => 'https://stocksnap.io/photo/soft-floral-XOYWCCW5PA',
'theming' => self::THEMING_MODE_DARK,
'primary_color' => '#D8A06C',
'background_color' => '#e4d2c1',
'primary_color' => '#9f652f',
],
'ted-moravec-morning-fog.jpg' => [
'attribution' => 'Morning fog (Ted Moravec, Public Domain)',
'description' => 'Background picture of a forest shrouded in fog',
'attribution_url' => 'https://flickr.com/photos/tmoravec/52392410261',
'theming' => self::THEMING_MODE_DARK,
'primary_color' => '#38A084',
'background_color' => '#f6f7f6',
'primary_color' => '#114c3b',
],
'stefanus-martanto-setyo-husodo-underwater-ocean.jpg' => [
'attribution' => 'Underwater ocean (Stefanus Martanto Setyo Husodo, CC0)',
'description' => 'Background picture of an underwater ocean',
'attribution_url' => 'https://stocksnap.io/photo/underwater-ocean-TJA9LBH4WS',
'background_color' => '#003351',
'primary_color' => '#04577e',
],
'zoltan-voros-rhythm-and-blues.jpg' => [
'attribution' => 'Rhythm and blues (Zoltán Vörös, CC BY)',
'description' => 'Abstract background picture of sand dunes during night',
'attribution_url' => 'https://flickr.com/photos/v923z/51634409289/',
'background_color' => '#1c2437',
'primary_color' => '#1c243c',
],
'anatoly-mikhaltsov-butterfly-wing-scale.jpg' => [
'attribution' => 'Butterfly wing scale (Anatoly Mikhaltsov, CC BY-SA)',
'description' => 'Background picture of a red-ish butterfly wing under microscope',
'attribution_url' => 'https://commons.wikimedia.org/wiki/File:%D0%A7%D0%B5%D1%88%D1%83%D0%B9%D0%BA%D0%B8_%D0%BA%D1%80%D1%8B%D0%BB%D0%B0_%D0%B1%D0%B0%D0%B1%D0%BE%D1%87%D0%BA%D0%B8.jpg',
'background_color' => '#652e11',
'primary_color' => '#a53c17',
],
'bernie-cetonia-aurata-take-off-composition.jpg' => [
'attribution' => 'Cetonia aurata take off composition (Bernie, Public Domain)',
'description' => 'Montage of a cetonia aurata bug that takes off with white background',
'attribution_url' => 'https://commons.wikimedia.org/wiki/File:Cetonia_aurata_take_off_composition_05172009.jpg',
'theming' => self::THEMING_MODE_DARK,
'primary_color' => '#869171',
'background_color' => '#dee0d3',
'primary_color' => '#56633d',
],
'dejan-krsmanovic-ribbed-red-metal.jpg' => [
'attribution' => 'Ribbed red metal (Dejan Krsmanovic, CC BY)',
'description' => 'Abstract background picture of red ribbed metal with two horizontal white elements on top of it',
'attribution_url' => 'https://www.flickr.com/photos/dejankrsmanovic/42971456774/',
'background_color' => '#9b171c',
'primary_color' => '#9c4236',
],
'eduardo-neves-pedra-azul.jpg' => [
'attribution' => 'Pedra azul milky way (Eduardo Neves, CC BY-SA)',
'description' => 'Background picture of the milky way during night with a mountain in front of it',
'attribution_url' => 'https://commons.wikimedia.org/wiki/File:Pedra_Azul_Milky_Way.jpg',
'background_color' => '#1d242d',
'primary_color' => '#4f6071',
],
'european-space-agency-barents-bloom.jpg' => [
'attribution' => 'Barents bloom (European Space Agency, CC BY-SA)',
'description' => 'Abstract background picture of blooming barents in blue and green colors',
'attribution_url' => 'https://www.esa.int/ESA_Multimedia/Images/2016/08/Barents_bloom',
'background_color' => '#1c383d',
'primary_color' => '#396475',
],
'hannes-fritz-flippity-floppity.jpg' => [
'attribution' => 'Flippity floppity (Hannes Fritz, CC BY-SA)',
'description' => 'Abstract background picture of many pairs of flip flops hanging on a wall in multiple colors',
'attribution_url' => 'http://hannes.photos/flippity-floppity',
'background_color' => '#5b2d53',
'primary_color' => '#98415a',
],
'hannes-fritz-roulette.jpg' => [
'attribution' => 'Roulette (Hannes Fritz, CC BY-SA)',
'description' => 'Background picture of a rotating giant wheel during night',
'attribution_url' => 'http://hannes.photos/roulette',
'background_color' => '#000000',
'primary_color' => '#845334',
],
'hannes-fritz-sea-spray.jpg' => [
'attribution' => 'Sea spray (Hannes Fritz, CC BY-SA)',
'description' => 'Background picture of a stone coast with fog and sea behind it',
'attribution_url' => 'http://hannes.photos/sea-spray',
'background_color' => '#333f47',
'primary_color' => '#4f6071',
],
'kamil-porembinski-clouds.jpg' => [
'attribution' => 'Clouds (Kamil Porembiński, CC BY-SA)',
'description' => 'Background picture of white clouds on in front of a blue sky',
'attribution_url' => 'https://www.flickr.com/photos/paszczak000/8715851521/',
'background_color' => self::DEFAULT_BACKGROUND_COLOR,
'primary_color' => self::DEFAULT_COLOR,
],
'bernard-spragg-new-zealand-fern.jpg' => [
'attribution' => 'New zealand fern (Bernard Spragg, CC0)',
'description' => 'Abstract background picture of fern leafes',
'attribution_url' => 'https://commons.wikimedia.org/wiki/File:NZ_Fern.(Blechnum_chambersii)_(11263534936).jpg',
'background_color' => '#0c3c03',
'primary_color' => '#316b26',
],
'rawpixel-pink-tapioca-bubbles.jpg' => [
'attribution' => 'Pink tapioca bubbles (Rawpixel, CC BY)',
'description' => 'Abstract background picture of pink tapioca bubbles',
'attribution_url' => 'https://www.flickr.com/photos/byrawpixel/27665140298/in/photostream/',
'theming' => self::THEMING_MODE_DARK,
'primary_color' => '#b17ab4',
'background_color' => '#c56e95',
'primary_color' => '#7b4e7e',
],
'nasa-waxing-crescent-moon.jpg' => [
'attribution' => 'Waxing crescent moon (NASA, Public Domain)',
'description' => 'Background picture of glowing earth in foreground and moon in the background',
'attribution_url' => 'https://www.nasa.gov/image-feature/a-waxing-crescent-moon',
'background_color' => '#000002',
'primary_color' => '#005ac1',
],
'tommy-chau-already.jpg' => [
'attribution' => 'Cityscape (Tommy Chau, CC BY)',
'description' => 'Background picture of a skyscraper city during night',
'attribution_url' => 'https://www.flickr.com/photos/90975693@N05/16910999368',
'background_color' => '#35229f',
'primary_color' => '#6a2af4',
],
'tommy-chau-lion-rock-hill.jpg' => [
'attribution' => 'Lion rock hill (Tommy Chau, CC BY)',
'description' => 'Background picture of mountains during sunset or sunrise',
'attribution_url' => 'https://www.flickr.com/photos/90975693@N05/17136440246',
'theming' => self::THEMING_MODE_DARK,
'primary_color' => '#c074a9',
'background_color' => '#cb92b7',
'primary_color' => '#7f4f70',
],
'lali-masriera-yellow-bricks.jpg' => [
'attribution' => 'Yellow bricks (Lali Masriera, CC BY)',
'description' => 'Background picture of yellow bricks with some yellow tubes',
'attribution_url' => 'https://www.flickr.com/photos/visualpanic/3982464447',
'theming' => self::THEMING_MODE_DARK,
'primary_color' => '#bc8210',
'background_color' => '#c78a19',
'primary_color' => '#7f5700',
],
];

private IRootFolder $rootFolder;
private IAppData $appData;
private IConfig $config;
private string $userId;
private ThemingDefaults $themingDefaults;

public function __construct(IRootFolder $rootFolder,
IAppData $appData,
IConfig $config,
?string $userId,
ThemingDefaults $themingDefaults) {
if ($userId === null) {
return;
}

$this->rootFolder = $rootFolder;
$this->config = $config;
$this->userId = $userId;
$this->appData = $appData;
$this->themingDefaults = $themingDefaults;
public function __construct(
private IRootFolder $rootFolder,
private IAppData $appData,
private IConfig $config,
private ?string $userId,
) {
}

public function setDefaultBackground(): void {
$this->config->deleteUserValue($this->userId, Application::APP_ID, 'background_image');
$this->config->deleteUserValue($this->userId, Application::APP_ID, 'background_color');
$this->config->deleteUserValue($this->userId, Application::APP_ID, 'primary_color');
}

/**
@@ -213,7 +232,9 @@ class BackgroundService {
* @throws NoUserException
*/
public function setFileBackground($path): void {
$this->config->setUserValue($this->userId, Application::APP_ID, 'background_image', self::BACKGROUND_CUSTOM);
if ($this->userId === null) {
throw new RuntimeException('No currently logged-in user');
}
$userFolder = $this->rootFolder->getUserFolder($this->userId);

/** @var File $file */
@@ -224,26 +245,46 @@ class BackgroundService {
throw new InvalidArgumentException('Invalid image file');
}

$meanColor = $this->calculateMeanColor($image);
if ($meanColor !== false) {
$this->setColorBackground($meanColor);
}

$this->getAppDataFolder()->newFile('background.jpg', $file->fopen('r'));
$this->config->setUserValue($this->userId, Application::APP_ID, 'background_image', self::BACKGROUND_CUSTOM);
}

public function setShippedBackground($fileName): void {
if ($this->userId === null) {
throw new RuntimeException('No currently logged-in user');
}
if (!array_key_exists($fileName, self::SHIPPED_BACKGROUNDS)) {
throw new InvalidArgumentException('The given file name is invalid');
}
$this->setColorBackground(self::SHIPPED_BACKGROUNDS[$fileName]['background_color']);
$this->config->setUserValue($this->userId, Application::APP_ID, 'background_image', $fileName);
$this->setColorBackground(self::SHIPPED_BACKGROUNDS[$fileName]['primary_color']);
$this->config->setUserValue($this->userId, Application::APP_ID, 'primary_color', self::SHIPPED_BACKGROUNDS[$fileName]['primary_color']);
}

/**
* Set the background to color only
*/
public function setColorBackground(string $color): void {
if ($this->userId === null) {
throw new RuntimeException('No currently logged-in user');
}
if (!preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', $color)) {
throw new InvalidArgumentException('The given color is invalid');
}
$this->config->setUserValue($this->userId, Application::APP_ID, 'background_color', $color);
$this->config->setUserValue($this->userId, Application::APP_ID, 'background_image', self::BACKGROUND_COLOR);
}

public function deleteBackgroundImage(): void {
$this->config->setUserValue($this->userId, Application::APP_ID, 'background_image', self::BACKGROUND_DISABLED);
if ($this->userId === null) {
throw new RuntimeException('No currently logged-in user');
}
$this->config->setUserValue($this->userId, Application::APP_ID, 'background_image', self::BACKGROUND_COLOR);
}

public function getBackground(): ?ISimpleFile {
@@ -258,6 +299,82 @@ class BackgroundService {
return null;
}

/**
* Called when a new global background (backgroundMime) is uploaded (admin setting)
* This sets all necessary app config values
* @param resource|string $path
* @return string|null The fallback background color - if any
*/
public function setGlobalBackground($path): string|null {
$image = new \OCP\Image();
$handle = is_resource($path) ? $path : fopen($path, 'rb');

if ($handle && $image->loadFromFileHandle($handle) !== false) {
$meanColor = $this->calculateMeanColor($image);
if ($meanColor !== false) {
$this->config->setAppValue(Application::APP_ID, 'background_color', $meanColor);
return $meanColor;
}
}
return null;
}

/**
* Calculate mean color of an given image
* It only takes the upper part into account so that a matching text color can be derived for the app menu
*/
private function calculateMeanColor(\OCP\Image $image): false|string {
/**
* Small helper to ensure one channel is returned as 8byte hex
*/
function toHex(int $channel): string {
$hex = dechex($channel);
return match (strlen($hex)) {
0 => '00',
1 => '0'.$hex,
2 => $hex,
default => 'ff',
};
}

$tempImage = new \OCP\Image();

// Crop to only analyze top bar
$resource = $image->cropNew(0, 0, $image->width(), min(max(50, (int)($image->height() * 0.125)), $image->height()));
if ($resource === false) {
return false;
}

$tempImage->setResource($resource);
if (!$tempImage->preciseResize(100, 7)) {
return false;
}

$resource = $tempImage->resource();
if ($resource === false) {
return false;
}

$reds = [];
$greens = [];
$blues = [];
for ($y = 0; $y < 7; $y++) {
for ($x = 0; $x < 100; $x++) {
$value = imagecolorat($resource, $x, $y);
if ($value === false) {
continue;
}
$reds[] = ($value >> 16) & 0xFF;
$greens[] = ($value >> 8) & 0xFF;
$blues[] = $value & 0xFF;
}
}
$meanColor = '#' . toHex((int)(array_sum($reds) / count($reds)));
$meanColor .= toHex((int)(array_sum($greens) / count($greens)));
$meanColor .= toHex((int)(array_sum($blues) / count($blues)));
return $meanColor;
}

/**
* Storing the data in appdata/theming/users/USERID
*

+ 15
- 13
apps/theming/lib/Service/JSDataService.php View File

@@ -28,38 +28,40 @@ namespace OCA\Theming\Service;

use OCA\Theming\ThemingDefaults;
use OCA\Theming\Util;
use OCP\IConfig;

class JSDataService implements \JsonSerializable {
private ThemingDefaults $themingDefaults;
private Util $util;
private IConfig $appConfig;
private ThemesService $themesService;

public function __construct(
ThemingDefaults $themingDefaults,
Util $util,
IConfig $appConfig,
ThemesService $themesService
private ThemingDefaults $themingDefaults,
private Util $util,
private ThemesService $themesService,
) {
$this->themingDefaults = $themingDefaults;
$this->util = $util;
$this->appConfig = $appConfig;
$this->themesService = $themesService;
}

public function jsonSerialize(): array {
return [
'name' => $this->themingDefaults->getName(),
'url' => $this->themingDefaults->getBaseUrl(),
'slogan' => $this->themingDefaults->getSlogan(),
'color' => $this->themingDefaults->getColorPrimary(),
'defaultColor' => $this->themingDefaults->getDefaultColorPrimary(),
'url' => $this->themingDefaults->getBaseUrl(),
'imprintUrl' => $this->themingDefaults->getImprintUrl(),
'privacyUrl' => $this->themingDefaults->getPrivacyUrl(),

'primaryColor' => $this->themingDefaults->getColorPrimary(),
'backgroundColor' => $this->themingDefaults->getColorBackground(),
'defaultPrimaryColor' => $this->themingDefaults->getDefaultColorPrimary(),
'defaultBackgroundColor' => $this->themingDefaults->getDefaultColorBackground(),
'inverted' => $this->util->invertTextColor($this->themingDefaults->getColorPrimary()),

'cacheBuster' => $this->util->getCacheBuster(),
'enabledThemes' => $this->themesService->getEnabledThemes(),

// deprecated use primaryColor
'color' => $this->themingDefaults->getColorPrimary(),
'' => 'color is deprecated since Nextcloud 29, use primaryColor instead'
];
}
}

+ 6
- 1
apps/theming/lib/Settings/Admin.php View File

@@ -30,6 +30,7 @@ namespace OCA\Theming\Settings;
use OCA\Theming\AppInfo\Application;
use OCA\Theming\Controller\ThemingController;
use OCA\Theming\ImageManager;
use OCA\Theming\Service\BackgroundService;
use OCA\Theming\ThemingDefaults;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
@@ -75,9 +76,13 @@ class Admin implements IDelegatedSettings {
'name' => $this->themingDefaults->getEntity(),
'url' => $this->themingDefaults->getBaseUrl(),
'slogan' => $this->themingDefaults->getSlogan(),
'color' => $this->themingDefaults->getDefaultColorPrimary(),
'primaryColor' => $this->themingDefaults->getDefaultColorPrimary(),
'backgroundColor' => $this->themingDefaults->getDefaultColorBackground(),
'logoMime' => $this->config->getAppValue(Application::APP_ID, 'logoMime', ''),
'allowedMimeTypes' => $allowedMimeTypes,
'backgroundURL' => $this->imageManager->getImageUrl('background'),
'defaultBackgroundURL' => $this->urlGenerator->linkTo(Application::APP_ID, 'img/background/' . BackgroundService::DEFAULT_BACKGROUND_IMAGE),
'defaultBackgroundColor' => BackgroundService::DEFAULT_BACKGROUND_COLOR,
'backgroundMime' => $this->config->getAppValue(Application::APP_ID, 'backgroundMime', ''),
'logoheaderMime' => $this->config->getAppValue(Application::APP_ID, 'logoheaderMime', ''),
'faviconMime' => $this->config->getAppValue(Application::APP_ID, 'faviconMime', ''),

+ 21
- 0
apps/theming/lib/Settings/Personal.php View File

@@ -26,6 +26,7 @@
namespace OCA\Theming\Settings;

use OCA\Theming\ITheme;
use OCA\Theming\Service\BackgroundService;
use OCA\Theming\Service\ThemesService;
use OCA\Theming\ThemingDefaults;
use OCP\App\IAppManager;
@@ -71,6 +72,26 @@ class Personal implements ISettings {
// Get the default app enforced by admin
$forcedDefaultApp = $this->appManager->getDefaultAppForUser(null, false);

/** List of all shipped backgrounds */
$this->initialStateService->provideInitialState('shippedBackgrounds', BackgroundService::SHIPPED_BACKGROUNDS);

/**
* Admin theming
*/
$this->initialStateService->provideInitialState('themingDefaults', [
/** URL of admin configured background image */
'backgroundImage' => $this->themingDefaults->getBackground(),
/** `backgroundColor` if disabled, mime type if defined and empty by default */
'backgroundMime' => $this->config->getAppValue('theming', 'backgroundMime', ''),
/** Admin configured background color */
'backgroundColor' => $this->themingDefaults->getDefaultColorBackground(),
/** Admin configured primary color */
'primaryColor' => $this->themingDefaults->getDefaultColorPrimary(),
/** Nextcloud default background image */
'defaultShippedBackground' => BackgroundService::DEFAULT_BACKGROUND_IMAGE,
]);

$this->initialStateService->provideInitialState('userBackgroundImage', $this->config->getUserValue($this->userId, 'theming', 'background_image', BackgroundService::BACKGROUND_DEFAULT));
$this->initialStateService->provideInitialState('themes', array_values($themes));
$this->initialStateService->provideInitialState('enforceTheme', $enforcedTheme);
$this->initialStateService->provideInitialState('isUserThemingDisabled', $this->themingDefaults->isUserThemingDisabled());

+ 34
- 44
apps/theming/lib/Themes/CommonThemeTrait.php View File

@@ -6,6 +6,7 @@ declare(strict_types=1);
*
* @author Joas Schilling <coding@schilljs.com>
* @author John Molakvoæ <skjnldsv@protonmail.com>
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license GNU AGPL version 3 or any later version
*
@@ -28,10 +29,12 @@ namespace OCA\Theming\Themes;
use OCA\Theming\AppInfo\Application;
use OCA\Theming\ImageManager;
use OCA\Theming\Service\BackgroundService;
use OCA\Theming\ThemingDefaults;
use OCA\Theming\Util;

trait CommonThemeTrait {
public Util $util;
public ThemingDefaults $themingDefaults;

/**
* Generate primary-related variables
@@ -58,7 +61,6 @@ trait CommonThemeTrait {
'--primary-invert-if-dark' => $this->util->invertTextColor($colorPrimaryElement) ? 'no' : 'invert(100%)',

'--color-primary' => $this->primaryColor,
'--color-primary-default' => $this->defaultPrimaryColor,
'--color-primary-text' => $this->util->invertTextColor($this->primaryColor) ? '#000000' : '#ffffff',
'--color-primary-hover' => $this->util->mix($this->primaryColor, $colorMainBackground, 60),
'--color-primary-light' => $colorPrimaryLight,
@@ -88,34 +90,33 @@ trait CommonThemeTrait {
protected function generateGlobalBackgroundVariables(): array {
$backgroundDeleted = $this->config->getAppValue(Application::APP_ID, 'backgroundMime', '') === 'backgroundColor';
$hasCustomLogoHeader = $this->util->isLogoThemed();
$isPrimaryBright = $this->util->invertTextColor($this->primaryColor);

$variables = [];
$backgroundColor = $this->themingDefaults->getColorBackground();

// Default last fallback values
$variables['--image-background-default'] = "url('" . $this->themingDefaults->getBackground() . "')";
$variables['--color-background-plain'] = $this->primaryColor;
$variables = [
'--color-background-plain' => $backgroundColor,
'--color-background-plain-text' => $this->util->invertTextColor($backgroundColor) ? '#000000' : '#ffffff',
'--background-image-invert-if-bright' => $this->util->invertTextColor($backgroundColor) ? 'invert(100%)' : 'no',
];

// Register image variables only if custom-defined
foreach (ImageManager::SUPPORTED_IMAGE_KEYS as $image) {
if ($this->imageManager->hasImage($image)) {
$imageUrl = $this->imageManager->getImageUrl($image);
// --image-background is overridden by user theming if logged in
$variables["--image-$image"] = "url('" . $imageUrl . "')";
} elseif ($image === 'background') {
// Apply default background if nothing is configured
$variables['--image-background'] = "url('" . $this->themingDefaults->getBackground() . "')";
}
}

// If primary as background has been request or if we have a custom primary colour
// let's not define the background image
// If a background has been requested let's not define the background image
if ($backgroundDeleted) {
$variables['--color-background-plain'] = $this->primaryColor;
$variables['--image-background-plain'] = 'yes';
$variables['--image-background'] = 'no';
// If no background image is set, we need to check against the shown primary colour
$variables['--background-image-invert-if-bright'] = $isPrimaryBright ? 'invert(100%)' : 'no';
$variables['--image-background'] = 'none';
}

if ($hasCustomLogoHeader) {
// prevent inverting the logo on bright colors if customized
$variables['--image-logoheader-custom'] = 'true';
}

@@ -130,48 +131,37 @@ trait CommonThemeTrait {
if ($user !== null
&& !$this->themingDefaults->isUserThemingDisabled()
&& $this->appManager->isEnabledForUser(Application::APP_ID)) {
$adminBackgroundDeleted = $this->config->getAppValue(Application::APP_ID, 'backgroundMime', '') === 'backgroundColor';
$backgroundImage = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'background_image', BackgroundService::BACKGROUND_DEFAULT);
$backgroundColor = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'background_color', $this->themingDefaults->getColorBackground());

$currentVersion = (int)$this->config->getUserValue($user->getUID(), Application::APP_ID, 'userCacheBuster', '0');
$isPrimaryBright = $this->util->invertTextColor($this->primaryColor);

// The user removed the background
if ($backgroundImage === BackgroundService::BACKGROUND_DISABLED) {
return [
// Might be defined already by admin theming, needs to be overridden
'--image-background' => 'none',
'--color-background-plain' => $this->primaryColor,
// If no background image is set, we need to check against the shown primary colour
'--background-image-invert-if-bright' => $isPrimaryBright ? 'invert(100%)' : 'no',
];
$isBackgroundBright = $this->util->invertTextColor($backgroundColor);
$backgroundTextColor = $this->util->invertTextColor($backgroundColor) ? '#000000' : '#ffffff';

$variables = [
'--color-background-plain' => $backgroundColor,
'--color-background-plain-text' => $backgroundTextColor,
'--background-image-invert-if-bright' => $isBackgroundBright ? 'invert(100%)' : 'no',
];

// Only use a background color without an image
if ($backgroundImage === BackgroundService::BACKGROUND_COLOR) {
// Might be defined already by admin theming, needs to be overridden
$variables['--image-background'] = 'none';
}

// The user uploaded a custom background
if ($backgroundImage === BackgroundService::BACKGROUND_CUSTOM) {
$cacheBuster = substr(sha1($user->getUID() . '_' . $currentVersion), 0, 8);
return [
'--image-background' => "url('" . $this->urlGenerator->linkToRouteAbsolute('theming.userTheme.getBackground') . "?v=$cacheBuster')",
'--color-background-plain' => $this->primaryColor,
];
}

// The user is using the default background and admin removed the background image
if ($backgroundImage === BackgroundService::BACKGROUND_DEFAULT && $adminBackgroundDeleted) {
return [
// --image-background is not defined in this case
'--color-background-plain' => $this->primaryColor,
'--background-image-invert-if-bright' => $isPrimaryBright ? 'invert(100%)' : 'no',
];
$variables['--image-background'] = "url('" . $this->urlGenerator->linkToRouteAbsolute('theming.userTheme.getBackground') . "?v=$cacheBuster')";
}

// The user picked a shipped background
if (isset(BackgroundService::SHIPPED_BACKGROUNDS[$backgroundImage])) {
return [
'--image-background' => "url('" . $this->urlGenerator->linkTo(Application::APP_ID, "img/background/$backgroundImage") . "')",
'--color-background-plain' => $this->primaryColor,
'--background-image-invert-if-bright' => BackgroundService::SHIPPED_BACKGROUNDS[$backgroundImage]['theming'] ?? null === BackgroundService::THEMING_MODE_DARK ? 'invert(100%)' : 'no',
];
$variables['--image-background'] = "url('" . $this->urlGenerator->linkTo(Application::APP_ID, "img/background/$backgroundImage") . "')";
}

return $variables;
}

return [];

+ 0
- 6
apps/theming/lib/Themes/DefaultTheme.php View File

@@ -27,7 +27,6 @@ namespace OCA\Theming\Themes;

use OCA\Theming\ImageManager;
use OCA\Theming\ITheme;
use OCA\Theming\Service\BackgroundService;
use OCA\Theming\ThemingDefaults;
use OCA\Theming\Util;
use OCP\App\IAppManager;
@@ -70,11 +69,6 @@ class DefaultTheme implements ITheme {

$this->defaultPrimaryColor = $this->themingDefaults->getDefaultColorPrimary();
$this->primaryColor = $this->themingDefaults->getColorPrimary();

// Override primary colors (if set) to improve accessibility
if ($this->primaryColor === BackgroundService::DEFAULT_COLOR) {
$this->primaryColor = BackgroundService::DEFAULT_ACCESSIBLE_COLOR;
}
}

public function getId(): string {

+ 87
- 58
apps/theming/lib/ThemingDefaults.php View File

@@ -7,6 +7,7 @@
* @author Bjoern Schiessle <bjoern@schiessle.org>
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
* @author Daniel Kesselberg <mail@danielkesselberg.de>
* @author Ferdinand Thiessen <opensource@fthiessen.de>
* @author Guillaume COMPAGNON <gcompagnon@outlook.com>
* @author Jan-Christoph Borchardt <hey@jancborchardt.net>
* @author Joachim Bauch <bauch@struktur.de>
@@ -55,22 +56,13 @@ use OCP\IUserSession;

class ThemingDefaults extends \OC_Defaults {

private IConfig $config;
private IL10N $l;
private ImageManager $imageManager;
private IUserSession $userSession;
private IURLGenerator $urlGenerator;
private ICacheFactory $cacheFactory;
private Util $util;
private IAppManager $appManager;
private INavigationManager $navigationManager;

private string $name;
private string $title;
private string $entity;
private string $productName;
private string $url;
private string $color;
private string $backgroundColor;
private string $primaryColor;
private string $docBaseUrl;

private string $iTunesAppId;
@@ -80,43 +72,28 @@ class ThemingDefaults extends \OC_Defaults {

/**
* ThemingDefaults constructor.
*
* @param IConfig $config
* @param IL10N $l
* @param ImageManager $imageManager
* @param IUserSession $userSession
* @param IURLGenerator $urlGenerator
* @param ICacheFactory $cacheFactory
* @param Util $util
* @param IAppManager $appManager
*/
public function __construct(IConfig $config,
IL10N $l,
IUserSession $userSession,
IURLGenerator $urlGenerator,
ICacheFactory $cacheFactory,
Util $util,
ImageManager $imageManager,
IAppManager $appManager,
INavigationManager $navigationManager
public function __construct(
private IConfig $config,
private IL10N $l,
private IUserSession $userSession,
private IURLGenerator $urlGenerator,
private ICacheFactory $cacheFactory,
private Util $util,
private ImageManager $imageManager,
private IAppManager $appManager,
private INavigationManager $navigationManager,
private BackgroundService $backgroundService,
) {
parent::__construct();
$this->config = $config;
$this->l = $l;
$this->imageManager = $imageManager;
$this->userSession = $userSession;
$this->urlGenerator = $urlGenerator;
$this->cacheFactory = $cacheFactory;
$this->util = $util;
$this->appManager = $appManager;
$this->navigationManager = $navigationManager;

$this->name = parent::getName();
$this->title = parent::getTitle();
$this->entity = parent::getEntity();
$this->productName = parent::getProductName();
$this->url = parent::getBaseUrl();
$this->color = parent::getColorPrimary();
$this->primaryColor = parent::getColorPrimary();
$this->backgroundColor = parent::getColorBackground();
$this->iTunesAppId = parent::getiTunesAppId();
$this->iOSClientUrl = parent::getiOSClientUrl();
$this->AndroidClientUrl = parent::getAndroidClientUrl();
@@ -224,7 +201,8 @@ class ThemingDefaults extends \OC_Defaults {
}

/**
* Color that is used for the header as well as for mail headers
* Color that is used for highlighting elements like important buttons
* If user theming is enabled then the user defined value is returned
*/
public function getColorPrimary(): string {
$user = $this->userSession->getUser();
@@ -238,32 +216,66 @@ class ThemingDefaults extends \OC_Defaults {

// user-defined primary color
if (!empty($user)) {
$themingBackgroundColor = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'background_color', '');
// If the user selected a specific colour
if (preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $themingBackgroundColor)) {
return $themingBackgroundColor;
$userPrimaryColor = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'primary_color', '');
if (preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $userPrimaryColor)) {
return $userPrimaryColor;
}
}

// If the default color is not valid, return the default background one
if (!preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $defaultColor)) {
return BackgroundService::DEFAULT_COLOR;
// Finally, return the system global primary color
return $defaultColor;
}

/**
* Color that is used for the page background (e.g. the header)
* If user theming is enabled then the user defined value is returned
*/
public function getColorBackground(): string {
$user = $this->userSession->getUser();

// admin-defined background color
$defaultColor = $this->getDefaultColorBackground();

if ($this->isUserThemingDisabled()) {
return $defaultColor;
}

// user-defined background color
if (!empty($user)) {
$userPrimaryColor = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'background_color', '');
if (preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $userPrimaryColor)) {
return $userPrimaryColor;
}
}

// Finally, return the system global primary color
// Finally, return the system global background color
return $defaultColor;
}

/**
* Return the default color primary
* Return the default primary color - only taking admin setting into account
*/
public function getDefaultColorPrimary(): string {
$color = $this->config->getAppValue(Application::APP_ID, 'color', '');
if (!preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $color)) {
return BackgroundService::DEFAULT_COLOR;
// try admin color
$defaultColor = $this->config->getAppValue(Application::APP_ID, 'primary_color', '');
if (preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $defaultColor)) {
return $defaultColor;
}

return $color;
// fall back to default primary color
return $this->primaryColor;
}

/**
* Default background color only taking admin setting into account
*/
public function getDefaultColorBackground(): string {
$defaultColor = $this->config->getAppValue(Application::APP_ID, 'background_color', '');
if (preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $defaultColor)) {
return $defaultColor;
}

return $this->backgroundColor;
}

/**
@@ -344,6 +356,7 @@ class ThemingDefaults extends \OC_Defaults {

/**
* @return array scss variables to overwrite
* @deprecated since Nextcloud 22 - https://github.com/nextcloud/server/issues/9940
*/
public function getScssVariables() {
$cacheBuster = $this->config->getAppValue('theming', 'cachebuster', '0');
@@ -366,7 +379,7 @@ class ThemingDefaults extends \OC_Defaults {
$variables['image-login-background'] = "url('".$this->imageManager->getImageUrl('background')."')";
$variables['image-login-plain'] = 'false';

if ($this->config->getAppValue('theming', 'color', '') !== '') {
if ($this->config->getAppValue('theming', 'primary_color', '') !== '') {
$variables['color-primary'] = $this->getColorPrimary();
$variables['color-primary-text'] = $this->getTextColorPrimary();
$variables['color-primary-element'] = $this->util->elementColor($this->getColorPrimary());
@@ -465,7 +478,7 @@ class ThemingDefaults extends \OC_Defaults {
}

/**
* Revert settings to the default value
* Revert admin settings to the default value
*
* @param string $setting setting which should be reverted
* @return string default value
@@ -485,8 +498,15 @@ class ThemingDefaults extends \OC_Defaults {
case 'slogan':
$returnValue = $this->getSlogan();
break;
case 'color':
$returnValue = $this->getDefaultColorPrimary();
case 'primary_color':
$returnValue = BackgroundService::DEFAULT_COLOR;
break;
case 'background_color':
// If a background image is set we revert to the mean image color
if ($this->imageManager->hasImage('background')) {
$file = $this->imageManager->getImage('background');
$returnValue = $this->backgroundService->setGlobalBackground($file->read()) ?? '';
}
break;
case 'logo':
case 'logoheader':
@@ -501,7 +521,16 @@ class ThemingDefaults extends \OC_Defaults {
}

/**
* Color of text in the header and primary buttons
* Color of text in the header menu
*
* @return string
*/
public function getTextColorBackground() {
return $this->util->invertTextColor($this->getColorBackground()) ? '#000000' : '#ffffff';
}

/**
* Color of text on primary buttons and other elements
*
* @return string
*/

+ 1
- 1
apps/theming/lib/Util.php View File

@@ -93,7 +93,7 @@ class Util {
$contrast = $this->colorContrast($color, $blurredBackground);

// Min. element contrast is 3:1 but we need to keep hover states in mind -> min 3.2:1
$minContrast = $highContrast ? 5.5 : 3.2;
$minContrast = $highContrast ? 5.6 : 3.2;

while ($contrast < $minContrast && $iteration++ < 100) {
$hsl = Color::hexToHsl($color);

+ 4
- 0
apps/theming/openapi.json View File

@@ -63,6 +63,7 @@
"color-element-dark",
"logo",
"background",
"background-text",
"background-plain",
"background-default",
"logoheader",
@@ -99,6 +100,9 @@
"background": {
"type": "string"
},
"background-text": {
"type": "string"
},
"background-plain": {
"type": "boolean"
},

+ 117
- 60
apps/theming/src/AdminTheming.vue View File

@@ -44,31 +44,50 @@
:placeholder="field.placeholder"
:type="field.type"
:value.sync="field.value"
@update:theming="$emit('update:theming')" />
@update:theming="refreshStyles" />

<!-- Primary color picker -->
<ColorPickerField :name="colorPickerField.name"
:default-value="colorPickerField.defaultValue"
:display-name="colorPickerField.displayName"
:value.sync="colorPickerField.value"
@update:theming="$emit('update:theming')" />
<ColorPickerField :name="primaryColorPickerField.name"
:description="primaryColorPickerField.description"
:default-value="primaryColorPickerField.defaultValue"
:display-name="primaryColorPickerField.displayName"
:value.sync="primaryColorPickerField.value"
data-admin-theming-setting-primary-color
@update:theming="refreshStyles" />

<!-- Background color picker -->
<ColorPickerField name="background_color"
:description="t('theming', 'Instead of a background image you can also configure a plain background color. If you use a background image changing this color will influence the color of the app menu icons.')"
:default-value.sync="defaultBackgroundColor"
:display-name="t('theming', 'Background color')"
:value.sync="backgroundColor"
data-admin-theming-setting-background-color
@update:theming="refreshStyles" />

<!-- Default background picker -->
<FileInputField v-for="field in fileInputFields"
:key="field.name"
:aria-label="field.ariaLabel"
:data-admin-theming-setting-file="field.name"
:default-mime-value="field.defaultMimeValue"
:display-name="field.displayName"
:mime-name="field.mimeName"
:mime-value.sync="field.mimeValue"
:name="field.name"
@update:theming="$emit('update:theming')" />
<FileInputField :aria-label="t('theming', 'Upload new logo')"
data-admin-theming-setting-file="logo"
:display-name="t('theming', 'Logo')"
mime-name="logoMime"
:mime-value.sync="logoMime"
name="logo"
@update:theming="refreshStyles" />

<FileInputField :aria-label="t('theming', 'Upload new background and login image')"
data-admin-theming-setting-file="background"
:display-name="t('theming', 'Background and login image')"
mime-name="backgroundMime"
:mime-value.sync="backgroundMime"
name="background"
@uploaded="backgroundURL = $event"
@update:theming="refreshStyles" />

<div class="admin-theming__preview" data-admin-theming-preview>
<div class="admin-theming__preview-logo" data-admin-theming-preview-logo />
</div>
</div>
</NcSettingsSection>

<NcSettingsSection :name="t('theming', 'Advanced options')">
<div class="admin-theming-advanced">
<TextField v-for="field in advancedTextFields"
@@ -80,7 +99,7 @@
:display-name="field.displayName"
:placeholder="field.placeholder"
:maxlength="field.maxlength"
@update:theming="$emit('update:theming')" />
@update:theming="refreshStyles" />
<FileInputField v-for="field in advancedFileInputFields"
:key="field.name"
:name="field.name"
@@ -89,7 +108,7 @@
:default-mime-value="field.defaultMimeValue"
:display-name="field.displayName"
:aria-label="field.ariaLabel"
@update:theming="$emit('update:theming')" />
@update:theming="refreshStyles" />
<CheckboxField :name="userThemingField.name"
:value="userThemingField.value"
:default-value="userThemingField.defaultValue"
@@ -97,7 +116,7 @@
:label="userThemingField.label"
:description="userThemingField.description"
data-admin-theming-setting-disable-user-theming
@update:theming="$emit('update:theming')" />
@update:theming="refreshStyles" />
<a v-if="!canThemeIcons"
:href="docUrlIcons"
rel="noreferrer noopener">
@@ -111,6 +130,7 @@

<script>
import { loadState } from '@nextcloud/initial-state'
import { refreshStyles } from './helpers/refreshStyles.js'

import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js'
@@ -121,9 +141,12 @@ import TextField from './components/admin/TextField.vue'
import AppMenuSection from './components/admin/AppMenuSection.vue'

const {
defaultBackgroundURL,

backgroundMime,
backgroundURL,
backgroundColor,
canThemeIcons,
color,
docUrl,
docUrlIcons,
faviconMime,
@@ -133,6 +156,7 @@ const {
logoMime,
name,
notThemableErrorMessage,
primaryColor,
privacyPolicyUrl,
slogan,
url,
@@ -170,32 +194,14 @@ const textFields = [
},
]

const colorPickerField = {
name: 'color',
value: color,
const primaryColorPickerField = {
name: 'primary_color',
value: primaryColor,
defaultValue: '#0082c9',
displayName: t('theming', 'Color'),
displayName: t('theming', 'Primary color'),
description: t('theming', 'The primary color is used for highlighting elements like important buttons. It might get slightly adjusted depending on the current color schema.'),
}

const fileInputFields = [
{
name: 'logo',
mimeName: 'logoMime',
mimeValue: logoMime,
defaultMimeValue: '',
displayName: t('theming', 'Logo'),
ariaLabel: t('theming', 'Upload new logo'),
},
{
name: 'background',
mimeName: 'backgroundMime',
mimeValue: backgroundMime,
defaultMimeValue: '',
displayName: t('theming', 'Background and login image'),
ariaLabel: t('theming', 'Upload new background and login image'),
},
]

const advancedTextFields = [
{
name: 'imprintUrl',
@@ -258,17 +264,17 @@ export default {
TextField,
},

emits: [
'update:theming',
],

textFields,

data() {
return {
backgroundMime,
backgroundURL,
backgroundColor,
defaultBackgroundColor: '#0069c3',

logoMime,

textFields,
colorPickerField,
fileInputFields,
primaryColorPickerField,
advancedTextFields,
advancedFileInputFields,
userThemingField,
@@ -281,6 +287,64 @@ export default {
notThemableErrorMessage,
}
},

computed: {
cssBackgroundImage() {
if (this.backgroundURL) {
return `url('${this.backgroundURL}')`
}
return 'unset'
},
},

watch: {
backgroundMime() {
if (this.backgroundMime === '') {
// Reset URL to default value for preview
this.backgroundURL = defaultBackgroundURL
} else if (this.backgroundMime === 'backgroundColor') {
// Reset URL to empty image when only color is configured
this.backgroundURL = ''
}
},
async backgroundURL() {
// When the background is changed we need to emulate the background color change
if (this.backgroundURL !== '') {
const color = await this.calculateDefaultBackground()
this.defaultBackgroundColor = color
this.backgroundColor = color
}
},
},

async mounted() {
if (this.backgroundURL) {
this.defaultBackgroundColor = await this.calculateDefaultBackground()
}
},

methods: {
refreshStyles,

/**
* Same as on server - if a user uploads an image the mean color will be set as the background color
*/
calculateDefaultBackground() {
const toHex = (num) => `00${num.toString(16)}`.slice(-2)

return new Promise((resolve, reject) => {
const img = new Image()
img.src = this.backgroundURL
img.onload = () => {
const context = document.createElement('canvas').getContext('2d')
context.imageSmoothingEnabled = true
context.drawImage(img, 0, 0, 1, 1)
resolve('#' + [...context.getImageData(0, 0, 1, 1).data.slice(0, 3)].map(toHex).join(''))
}
img.onerror = reject
})
},
},
}
</script>

@@ -300,15 +364,8 @@ export default {
background-position: center;
text-align: center;
margin-top: 10px;
/* This is basically https://github.com/nextcloud/server/blob/master/core/css/guest.css
But without the user variables. That way the admin can preview the render as guest*/
/* As guest, there is no user color color-background-plain */
background-color: var(--color-primary-element-default);
/* As guest, there is no user background (--image-background)
1. Empty background if defined
2. Else default background
3. Finally default gradient (should not happened, the background is always defined anyway) */
background-image: var(--image-background-plain, var(--image-background-default));
background-color: v-bind('backgroundColor');
background-image: v-bind('cssBackgroundImage');

&-logo {
width: 20%;

apps/theming/src/UserThemes.vue → apps/theming/src/UserTheming.vue View File

@@ -53,20 +53,27 @@
</div>
</NcSettingsSection>

<NcSettingsSection :name="t('theming', 'Background and color')"
class="background"
data-user-theming-background-disabled>
<template v-if="isUserThemingDisabled">
<p>{{ t('theming', 'Customization has been disabled by your administrator') }}</p>
</template>
<template v-else>
<p>{{ t('theming', 'The background can be set to an image from the default set, a custom uploaded image, or a plain color. The primary color will automatically be adapted based on this and used for elements like folder icons, primary buttons and highlights.') }}</p>
<BackgroundSettings class="background__grid" @update:background="refreshGlobalStyles" />
</template>
<NcSettingsSection :name="t('theming', 'Primary color')"
:description="isUserThemingDisabled
? t('theming', 'Customization has been disabled by your administrator')
: t('theming', 'Set a primary color to highlight important elements. The color used for elements such as primary buttons might differ a bit as it gets adjusted to fulfill accessibility requirements.')">
<UserPrimaryColor v-if="!isUserThemingDisabled"
ref="primaryColor"
@refresh-styles="refreshGlobalStyles" />
</NcSettingsSection>

<NcSettingsSection :name="t('theming', 'Keyboard shortcuts')">
<p>{{ t('theming', 'In some cases keyboard shortcuts can interfere with accessibility tools. In order to allow focusing on your tool correctly you can disable all keyboard shortcuts here. This will also disable all available shortcuts in apps.') }}</p>
<NcSettingsSection class="background"
:name="t('theming', 'Background and color')"
:description="isUserThemingDisabled
? t('theming', 'Customization has been disabled by your administrator')
: t('theming', 'The background can be set to an image from the default set, a custom uploaded image, or a plain color.')">
<BackgroundSettings v-if="!isUserThemingDisabled"
class="background__grid"
@update:background="refreshGlobalStyles" />
</NcSettingsSection>

<NcSettingsSection :name="t('theming', 'Keyboard shortcuts')"
:description="t('theming', 'In some cases keyboard shortcuts can interfere with accessibility tools. In order to allow focusing on your tool correctly you can disable all keyboard shortcuts here. This will also disable all available shortcuts in apps.')">
<NcCheckboxRadioSwitch class="theming__preview-toggle"
:checked.sync="shortcutsDisabled"
type="switch"
@@ -82,13 +89,17 @@
<script>
import { generateOcsUrl } from '@nextcloud/router'
import { loadState } from '@nextcloud/initial-state'
import { refreshStyles } from './helpers/refreshStyles'

import axios from '@nextcloud/axios'

import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js'

import BackgroundSettings from './components/BackgroundSettings.vue'
import ItemPreview from './components/ItemPreview.vue'
import UserAppMenuSection from './components/UserAppMenuSection.vue'
import UserPrimaryColor from './components/UserPrimaryColor.vue'

const availableThemes = loadState('theming', 'themes', [])
const enforceTheme = loadState('theming', 'enforceTheme', '')
@@ -97,7 +108,7 @@ const shortcutsDisabled = loadState('theming', 'shortcutsDisabled', false)
const isUserThemingDisabled = loadState('theming', 'isUserThemingDisabled')

export default {
name: 'UserThemes',
name: 'UserTheming',

components: {
ItemPreview,
@@ -105,6 +116,7 @@ export default {
NcSettingsSection,
BackgroundSettings,
UserAppMenuSection,
UserPrimaryColor,
},

data() {
@@ -173,20 +185,9 @@ export default {

methods: {
// Refresh server-side generated theming CSS
refreshGlobalStyles() {
[...document.head.querySelectorAll('link.theme')].forEach(theme => {
const url = new URL(theme.href)
url.searchParams.set('v', Date.now())
const newTheme = theme.cloneNode()
newTheme.href = url.toString()
newTheme.onload = () => theme.remove()
document.head.append(newTheme)
})
},

updateBackground(data) {
this.background = (data.type === 'custom' || data.type === 'default') ? data.type : data.value
this.refreshGlobalStyles()
async refreshGlobalStyles() {
await refreshStyles()
this.$nextTick(() => this.$refs.primaryColor.reload())
},

changeTheme({ enabled, id }) {

+ 0
- 2
apps/theming/src/admin-settings.js View File

@@ -22,7 +22,6 @@
import { getRequestToken } from '@nextcloud/auth'
import Vue from 'vue'

import { refreshStyles } from './helpers/refreshStyles.js'
import App from './AdminTheming.vue'

// eslint-disable-next-line camelcase
@@ -34,4 +33,3 @@ Vue.prototype.t = t
const View = Vue.extend(App)
const theming = new View()
theming.$mount('#admin-theming')
theming.$on('update:theming', refreshStyles)

+ 56
- 59
apps/theming/src/components/BackgroundSettings.vue View File

@@ -32,15 +32,34 @@
'background background__filepicker': true,
'background--active': backgroundImage === 'custom'
}"
:data-color-bright="invertTextColor(Theming.color)"
data-user-theming-background-custom
tabindex="0"
@click="pickFile">
{{ t('theming', 'Custom background') }}
<ImageEdit v-if="backgroundImage !== 'custom'" :size="26" />
<ImageEdit v-if="backgroundImage !== 'custom'" :size="20" />
<Check :size="44" />
</button>

<!-- Custom color picker -->
<NcColorPicker v-model="Theming.backgroundColor" @update:value="debouncePickColor">
<button :class="{
'icon-loading': loading === 'color',
'background background__color': true,
'background--active': backgroundImage === 'color'
}"
:aria-pressed="backgroundImage === 'color'"
:data-color="Theming.backgroundColor"
:data-color-bright="invertTextColor(Theming.backgroundColor)"
:style="{ backgroundColor: Theming.backgroundColor, '--border-color': Theming.backgroundColor}"
data-user-theming-background-color
tabindex="0"
@click="backgroundImage !== 'color' && debouncePickColor(Theming.backgroundColor)">
{{ t('theming', 'Plain background') /* TRANSLATORS: Background using a single color */ }}
<ColorPalette v-if="backgroundImage !== 'color'" :size="20" />
<Check :size="44" />
</button>
</NcColorPicker>

<!-- Default background -->
<button :aria-pressed="backgroundImage === 'default'"
:class="{
@@ -48,8 +67,8 @@
'background background__default': true,
'background--active': backgroundImage === 'default'
}"
:data-color-bright="invertTextColor(Theming.defaultColor)"
:style="{ '--border-color': Theming.defaultColor }"
:data-color-bright="invertTextColor(Theming.defaultBackgroundColor)"
:style="{ '--border-color': Theming.defaultBackgroundColor }"
data-user-theming-background-default
tabindex="0"
@click="setDefault">
@@ -57,31 +76,6 @@
<Check :size="44" />
</button>

<!-- Custom color picker -->
<div class="background-color"
data-user-theming-background-color>
<NcColorPicker v-model="Theming.color"
@input="debouncePickColor">
<NcButton type="ternary">
{{ t('theming', 'Change color') }}
</NcButton>
</NcColorPicker>
</div>

<!-- Remove background -->
<button :aria-pressed="isBackgroundDisabled"
:class="{
'background background__delete': true,
'background--active': isBackgroundDisabled
}"
data-user-theming-background-clear
tabindex="0"
@click="removeBackground">
{{ t('theming', 'No background') }}
<Close v-if="!isBackgroundDisabled" :size="32" />
<Check :size="44" />
</button>

<!-- Background set selection -->
<button v-for="shippedBackground in shippedBackgrounds"
:key="shippedBackground.name"
@@ -93,7 +87,7 @@
'icon-loading': loading === shippedBackground.name,
'background--active': backgroundImage === shippedBackground.name
}"
:data-color-bright="shippedBackground.details.theming === 'dark'"
:data-color-bright="invertTextColor(shippedBackground.details.background_color)"
:data-user-theming-background-shipped="shippedBackground.name"
:style="{ backgroundImage: 'url(' + shippedBackground.preview + ')', '--border-color': shippedBackground.details.primary_color }"
tabindex="0"
@@ -112,17 +106,20 @@ import { Palette } from 'node-vibrant/lib/color.js'
import axios from '@nextcloud/axios'
import debounce from 'debounce'
import NcColorPicker from '@nextcloud/vue/dist/Components/NcColorPicker.js'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import Vibrant from 'node-vibrant'

import Check from 'vue-material-design-icons/Check.vue'
import Close from 'vue-material-design-icons/Close.vue'
import ImageEdit from 'vue-material-design-icons/ImageEdit.vue'
import ColorPalette from 'vue-material-design-icons/Palette.vue'

const backgroundImage = loadState('theming', 'backgroundImage')
const shippedBackgroundList = loadState('theming', 'shippedBackgrounds')
const themingDefaultBackground = loadState('theming', 'themingDefaultBackground')
const defaultShippedBackground = loadState('theming', 'defaultShippedBackground')
const backgroundImage = loadState('theming', 'userBackgroundImage')
const {
backgroundImage: defaultBackgroundImage,
backgroundColor: defaultBackgroundColor,
backgroundMime: defaultBackgroundMime,
defaultShippedBackground,
} = loadState('theming', 'themingDefaults')

const prefixWithBaseUrl = (url) => generateFilePath('theming', '', 'img/background/') + url

@@ -131,9 +128,8 @@ export default {

components: {
Check,
Close,
ColorPalette,
ImageEdit,
NcButton,
NcColorPicker,
},

@@ -150,7 +146,12 @@ export default {
computed: {
shippedBackgrounds() {
return Object.keys(shippedBackgroundList)
.map(fileName => {
.filter((background) => {
// If the admin did not changed the global background
// let's hide the default background to not show it twice
return background !== defaultShippedBackground || !this.isGlobalBackgroundDefault
})
.map((fileName) => {
return {
name: fileName,
url: prefixWithBaseUrl(fileName),
@@ -158,27 +159,18 @@ export default {
details: shippedBackgroundList[fileName],
}
})
.filter(background => {
// If the admin did not changed the global background
// let's hide the default background to not show it twice
if (!this.isGlobalBackgroundDeleted && !this.isGlobalBackgroundDefault) {
return background.name !== defaultShippedBackground
}
return true
})
},

isGlobalBackgroundDefault() {
return !!themingDefaultBackground
return defaultBackgroundMime === ''
},

isGlobalBackgroundDeleted() {
return themingDefaultBackground === 'backgroundColor'
return defaultBackgroundMime === 'backgroundColor'
},

isBackgroundDisabled() {
return this.backgroundImage === 'disabled'
|| !this.backgroundImage
cssDefaultBackgroundImage() {
return `url('${defaultBackgroundImage}')`
},
},

@@ -226,7 +218,7 @@ export default {
async update(data) {
// Update state
this.backgroundImage = data.backgroundImage
this.Theming.color = data.backgroundColor
this.Theming.backgroundColor = data.backgroundColor

// Notify parent and reload style
this.$emit('update:background')
@@ -257,12 +249,12 @@ export default {
this.update(result.data)
},

async pickColor(event) {
async pickColor(color) {
this.loading = 'color'
const color = event?.target?.dataset?.color || this.Theming?.color || '#0082c9'
const result = await axios.post(generateUrl('/apps/theming/background/color'), { color })
this.update(result.data)
const { data } = await axios.post(generateUrl('/apps/theming/background/color'), { color: color || '#0082c9' })
this.update(data)
},

debouncePickColor: debounce(function(...args) {
this.pickColor(...args)
}, 200),
@@ -359,21 +351,26 @@ export default {
height: 96px;
margin: 8px;
text-align: center;
word-wrap: break-word;
hyphens: auto;
border: 2px solid var(--color-main-background);
border-radius: var(--border-radius-large);
background-position: center center;
background-size: cover;

&__filepicker {
background-color: var(--color-main-text);
background-color: var(--color-background-dark);

&.background--active {
color: white;
color: var(--color-background-plain-text);
background-image: var(--image-background);
}
}

&__default {
background-color: var(--color-primary-default);
background-image: linear-gradient(to bottom, rgba(23, 23, 23, 0.5), rgba(23, 23, 23, 0.5)), var(--image-background-plain, var(--image-background-default));
background-color: var(--color-background-plain);
background-image: linear-gradient(to bottom, rgba(23, 23, 23, 0.5), rgba(23, 23, 23, 0.5)), v-bind(cssDefaultBackgroundImage);
}

&__filepicker, &__default, &__color {

+ 156
- 0
apps/theming/src/components/UserPrimaryColor.vue View File

@@ -0,0 +1,156 @@
<template>
<div class="primary-color__wrapper">
<NcColorPicker v-model="primaryColor"
data-user-theming-primary-color
@update:value="debouncedOnUpdate">
<button ref="trigger"
class="color-container primary-color__trigger"
:style="{ 'background-color': primaryColor }"
data-user-theming-primary-color-trigger>
{{ t('theming', 'Primary color') }}
<NcLoadingIcon v-if="loading" />
<IconColorPalette v-else :size="20" />
</button>
</NcColorPicker>
<NcButton type="tertiary" :disabled="isdefaultPrimaryColor" @click="onReset">
<template #icon>
<IconUndo :size="20" />
</template>
{{ t('theming', 'Reset primary color') }}
</NcButton>
</div>
</template>

<script lang="ts">
import { showError } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n'
import { generateOcsUrl } from '@nextcloud/router'
import { colord } from 'colord'
import { debounce } from 'debounce'
import { defineComponent } from 'vue'

import axios from '@nextcloud/axios'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcColorPicker from '@nextcloud/vue/dist/Components/NcColorPicker.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import IconColorPalette from 'vue-material-design-icons/Palette.vue'
import IconUndo from 'vue-material-design-icons/UndoVariant.vue'

const { primaryColor, defaultPrimaryColor } = loadState('theming', 'data', { primaryColor: '#0082c9', defaultPrimaryColor: '#0082c9' })

export default defineComponent({
name: 'UserPrimaryColor',

components: {
IconColorPalette,
IconUndo,
NcButton,
NcColorPicker,
NcLoadingIcon,
},

emits: ['refresh-styles'],

data() {
return {
primaryColor,
loading: false,
}
},

computed: {
isdefaultPrimaryColor() {
return colord(this.primaryColor).isEqual(colord(defaultPrimaryColor))
},

debouncedOnUpdate() {
return debounce(this.onUpdate, 500)
},
},

methods: {
t,

/**
* Global styles are reloaded so we might need to update the current value
*/
reload() {
const trigger = this.$refs.trigger as HTMLButtonElement
const newColor = window.getComputedStyle(trigger).backgroundColor
if (newColor.toLowerCase() !== this.primaryColor) {
this.primaryColor = newColor
}
},

onReset() {
this.primaryColor = defaultPrimaryColor
this.onUpdate(null)
},

async onUpdate(value: string | null) {
this.loading = true
const url = generateOcsUrl('apps/provisioning_api/api/v1/config/users/{appId}/{configKey}', {
appId: 'theming',
configKey: 'primary_color',
})
try {
if (value) {
await axios.post(url, {
configValue: value,
})
} else {
await axios.delete(url)
}
this.$emit('refresh-styles')
} catch (e) {
console.error('Could not update primary color', e)
showError(t('theming', 'Could not set primary color'))
}
this.loading = false
},
},
})
</script>

<style scoped lang="scss">
.primary-color {
&__wrapper {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 12px;
}

&__trigger {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;

background-color: var(--color-primary);
color: var(--color-primary-text);
width: 350px;
max-width: 100vw;
height: 96px;

word-wrap: break-word;
hyphens: auto;

border: 2px solid var(--color-main-background);
border-radius: var(--border-radius-large);

&:active {
background-color: var(--color-primary-hover) !important;
}

&:hover,
&:focus,
&:focus-visible {
border-color: var(--color-main-background) !important;
outline: 2px solid var(--color-main-text) !important;
}
}
}
</style>

+ 59
- 8
apps/theming/src/components/admin/ColorPickerField.vue View File

@@ -26,27 +26,35 @@
<div class="field__row">
<NcColorPicker :value.sync="localValue"
:advanced-fields="true"
data-admin-theming-setting-primary-color-picker
@update:value="debounceSave">
<NcButton type="secondary"
:id="id">
<NcButton :id="id"
class="field__button"
type="primary"
:aria-label="t('theming', 'Select a custom color')"
data-admin-theming-setting-color-picker>
<template #icon>
<Palette :size="20" />
<NcLoadingIcon v-if="loading"
:appearance="calculatedTextColor === '#ffffff' ? 'light' : 'dark'"
:size="20" />
<Palette v-else :size="20" />
</template>
{{ t('theming', 'Change color') }}
{{ value }}
</NcButton>
</NcColorPicker>
<div class="field__color-preview" data-admin-theming-setting-primary-color />
<div class="field__color-preview" data-admin-theming-setting-color />
<NcButton v-if="value !== defaultValue"
type="tertiary"
:aria-label="t('theming', 'Reset to default')"
data-admin-theming-setting-primary-color-reset
data-admin-theming-setting-color-reset
@click="undo">
<template #icon>
<Undo :size="20" />
</template>
</NcButton>
</div>
<div v-if="description" class="description">
{{ description }}
</div>

<NcNoteCard v-if="errorMessage"
type="error"
@@ -58,8 +66,10 @@

<script>
import { debounce } from 'debounce'
import { colord } from 'colord'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcColorPicker from '@nextcloud/vue/dist/Components/NcColorPicker.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
import Undo from 'vue-material-design-icons/UndoVariant.vue'
import Palette from 'vue-material-design-icons/Palette.vue'
@@ -72,6 +82,7 @@ export default {
components: {
NcButton,
NcColorPicker,
NcLoadingIcon,
NcNoteCard,
Undo,
Palette,
@@ -86,10 +97,18 @@ export default {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
value: {
type: String,
required: true,
},
textColor: {
type: String,
default: null,
},
defaultValue: {
type: String,
required: true,
@@ -100,9 +119,33 @@ export default {
},
},

emits: ['update:theming'],

data() {
return {
loading: false,
}
},

computed: {
calculatedTextColor() {
const color = colord(this.value)
return color.isLight() ? '#000000' : '#ffffff'
},
usedTextColor() {
if (this.textColor) {
return this.textColor
}
return this.calculatedTextColor
},
},

methods: {
debounceSave: debounce(async function() {
this.loading = true
await this.save()
this.$emit('update:theming')
this.loading = false
}, 200),
},
}
@@ -110,12 +153,20 @@ export default {

<style lang="scss" scoped>
@import './shared/field.scss';
.description {
color: var(--color-text-maxcontrast);
}

.field {
&__button {
background-color: v-bind('value') !important;
color: v-bind('usedTextColor') !important;
}

&__color-preview {
width: var(--default-clickable-area);
border-radius: var(--border-radius-large);
background-color: var(--color-primary-default);
background-color: v-bind('value');
}
}
</style>

+ 3
- 2
apps/theming/src/components/admin/FileInputField.vue View File

@@ -126,7 +126,7 @@ export default {
},
defaultMimeValue: {
type: String,
required: true,
default: '',
},
displayName: {
type: String,
@@ -182,9 +182,10 @@ export default {
const url = generateUrl('/apps/theming/ajax/uploadImage')
try {
this.showLoading = true
await axios.post(url, formData)
const { data } = await axios.post(url, formData)
this.showLoading = false
this.$emit('update:mime-value', file.type)
this.$emit('uploaded', data.data.url)
this.handleSuccess()
} catch (e) {
this.showLoading = false

+ 15
- 5
apps/theming/src/helpers/refreshStyles.js View File

@@ -20,14 +20,24 @@
*
*/

export const refreshStyles = () => {
// Refresh server-side generated theming CSS
[...document.head.querySelectorAll('link.theme')].forEach(theme => {
/**
* Refresh server-side generated theming CSS
* This resolves when all themes are reloaded
*/
export async function refreshStyles() {
const themes = [...document.head.querySelectorAll('link.theme')]
const promises = themes.map((theme) => new Promise((resolve) => {
const url = new URL(theme.href)
url.searchParams.set('v', Date.now())
const newTheme = theme.cloneNode()
newTheme.href = url.toString()
newTheme.onload = () => theme.remove()
newTheme.onload = () => {
theme.remove()
resolve()
}
document.head.append(newTheme)
})
}))

// Wait until all themes are loaded
await Promise.allSettled(promises)
}

+ 6
- 2
apps/theming/src/mixins/admin/TextValueMixin.js View File

@@ -64,10 +64,14 @@ export default {
this.reset()
const url = generateUrl('/apps/theming/ajax/undoChanges')
try {
await axios.post(url, {
const { data } = await axios.post(url, {
setting: this.name,
})
this.$emit('update:value', this.defaultValue)

if (data.data.value) {
this.$emit('update:defaultValue', data.data.value)
}
this.$emit('update:value', data.data.value || this.defaultValue)
this.handleSuccess()
} catch (e) {
this.errorMessage = e.response.data.data?.message

+ 1
- 1
apps/theming/src/personal-settings.js View File

@@ -23,7 +23,7 @@ import { getRequestToken } from '@nextcloud/auth'
import Vue from 'vue'

import { refreshStyles } from './helpers/refreshStyles.js'
import App from './UserThemes.vue'
import App from './UserTheming.vue'

// eslint-disable-next-line camelcase
__webpack_nonce__ = btoa(getRequestToken())

+ 28
- 11
apps/theming/tests/CapabilitiesTest.php View File

@@ -37,6 +37,7 @@ use OCP\Files\IAppData;
use OCP\IConfig;
use OCP\IURLGenerator;
use OCP\IUserSession;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;

/**
@@ -45,16 +46,16 @@ use Test\TestCase;
* @package OCA\Theming\Tests
*/
class CapabilitiesTest extends TestCase {
/** @var ThemingDefaults|\PHPUnit\Framework\MockObject\MockObject */
/** @var ThemingDefaults|MockObject */
protected $theming;

/** @var IURLGenerator|\PHPUnit\Framework\MockObject\MockObject */
/** @var IURLGenerator|MockObject */
protected $url;

/** @var IConfig|\PHPUnit\Framework\MockObject\MockObject */
/** @var IConfig|MockObject */
protected $config;

/** @var Util|\PHPUnit\Framework\MockObject\MockObject */
/** @var Util|MockObject */
protected $util;

protected IUserSession $userSession;
@@ -66,16 +67,22 @@ class CapabilitiesTest extends TestCase {
parent::setUp();

$this->theming = $this->createMock(ThemingDefaults::class);
$this->url = $this->getMockBuilder(IURLGenerator::class)->getMock();
$this->url = $this->createMock(IURLGenerator::class);
$this->config = $this->createMock(IConfig::class);
$this->util = $this->createMock(Util::class);
$this->userSession = $this->createMock(IUserSession::class);
$this->capabilities = new Capabilities($this->theming, $this->util, $this->url, $this->config, $this->userSession);
$this->capabilities = new Capabilities(
$this->theming,
$this->util,
$this->url,
$this->config,
$this->userSession,
);
}

public function dataGetCapabilities() {
return [
['name', 'url', 'slogan', '#FFFFFF', '#000000', 'logo', 'background', 'http://absolute/', true, [
['name', 'url', 'slogan', '#FFFFFF', '#000000', 'logo', 'background', '#fff', '#000', 'http://absolute/', true, [
'name' => 'name',
'url' => 'url',
'slogan' => 'slogan',
@@ -86,12 +93,13 @@ class CapabilitiesTest extends TestCase {
'color-element-dark' => '#FFFFFF',
'logo' => 'http://absolute/logo',
'background' => 'http://absolute/background',
'background-text' => '#000',
'background-plain' => false,
'background-default' => false,
'logoheader' => 'http://absolute/logo',
'favicon' => 'http://absolute/logo',
]],
['name1', 'url2', 'slogan3', '#01e4a0', '#ffffff', 'logo5', 'background6', 'http://localhost/', false, [
['name1', 'url2', 'slogan3', '#01e4a0', '#ffffff', 'logo5', 'background6', '#fff', '#000', 'http://localhost/', false, [
'name' => 'name1',
'url' => 'url2',
'slogan' => 'slogan3',
@@ -102,12 +110,13 @@ class CapabilitiesTest extends TestCase {
'color-element-dark' => '#01e4a0',
'logo' => 'http://localhost/logo5',
'background' => 'http://localhost/background6',
'background-text' => '#000',
'background-plain' => false,
'background-default' => true,
'logoheader' => 'http://localhost/logo5',
'favicon' => 'http://localhost/logo5',
]],
['name1', 'url2', 'slogan3', '#000000', '#ffffff', 'logo5', 'backgroundColor', 'http://localhost/', true, [
['name1', 'url2', 'slogan3', '#000000', '#ffffff', 'logo5', 'backgroundColor', '#000000', '#ffffff', 'http://localhost/', true, [
'name' => 'name1',
'url' => 'url2',
'slogan' => 'slogan3',
@@ -118,12 +127,13 @@ class CapabilitiesTest extends TestCase {
'color-element-dark' => '#4d4d4d',
'logo' => 'http://localhost/logo5',
'background' => '#000000',
'background-text' => '#ffffff',
'background-plain' => true,
'background-default' => false,
'logoheader' => 'http://localhost/logo5',
'favicon' => 'http://localhost/logo5',
]],
['name1', 'url2', 'slogan3', '#000000', '#ffffff', 'logo5', 'backgroundColor', 'http://localhost/', false, [
['name1', 'url2', 'slogan3', '#000000', '#ffffff', 'logo5', 'backgroundColor', '#000000', '#ffffff', 'http://localhost/', false, [
'name' => 'name1',
'url' => 'url2',
'slogan' => 'slogan3',
@@ -134,6 +144,7 @@ class CapabilitiesTest extends TestCase {
'color-element-dark' => '#4d4d4d',
'logo' => 'http://localhost/logo5',
'background' => '#000000',
'background-text' => '#ffffff',
'background-plain' => true,
'background-default' => true,
'logoheader' => 'http://localhost/logo5',
@@ -155,7 +166,7 @@ class CapabilitiesTest extends TestCase {
* @param bool $backgroundThemed
* @param string[] $expected
*/
public function testGetCapabilities($name, $url, $slogan, $color, $textColor, $logo, $background, $baseUrl, $backgroundThemed, array $expected) {
public function testGetCapabilities($name, $url, $slogan, $color, $textColor, $logo, $background, $backgroundColor, $backgroundTextColor, $baseUrl, $backgroundThemed, array $expected) {
$this->config->expects($this->once())
->method('getAppValue')
->willReturn($background);
@@ -168,6 +179,12 @@ class CapabilitiesTest extends TestCase {
$this->theming->expects($this->once())
->method('getSlogan')
->willReturn($slogan);
$this->theming->expects($this->once())
->method('getColorBackground')
->willReturn($backgroundColor);
$this->theming->expects($this->once())
->method('getTextColorBackground')
->willReturn($backgroundTextColor);
$this->theming->expects($this->atLeast(1))
->method('getDefaultColorPrimary')
->willReturn($color);

+ 7
- 14
apps/theming/tests/Controller/ThemingControllerTest.php View File

@@ -42,13 +42,11 @@ use OCP\App\IAppManager;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Files\IAppData;
use OCP\Files\NotFoundException;
use OCP\Files\SimpleFS\ISimpleFile;
use OCP\IConfig;
use OCP\IL10N;
use OCP\IRequest;
use OCP\ITempManager;
use OCP\IURLGenerator;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;
@@ -64,17 +62,13 @@ class ThemingControllerTest extends TestCase {
private $l10n;
/** @var ThemingController */
private $themingController;
/** @var ITempManager */
private $tempManager;
/** @var IAppManager|MockObject */
private $appManager;
/** @var IAppData|MockObject */
private $appData;
/** @var ImageManager|MockObject */
private $imageManager;
/** @var IURLGenerator|MockObject */
private $urlGenerator;
/** @var ThemeService|MockObject */
/** @var ThemesService|MockObject */
private $themesService;

protected function setUp(): void {
@@ -82,9 +76,7 @@ class ThemingControllerTest extends TestCase {
$this->config = $this->createMock(IConfig::class);
$this->themingDefaults = $this->createMock(ThemingDefaults::class);
$this->l10n = $this->createMock(L10N::class);
$this->appData = $this->createMock(IAppData::class);
$this->appManager = $this->createMock(IAppManager::class);
$this->tempManager = \OC::$server->getTempManager();
$this->urlGenerator = $this->createMock(IURLGenerator::class);
$this->imageManager = $this->createMock(ImageManager::class);
$this->themesService = $this->createMock(ThemesService::class);
@@ -102,8 +94,6 @@ class ThemingControllerTest extends TestCase {
$this->config,
$this->themingDefaults,
$this->l10n,
$this->tempManager,
$this->appData,
$this->urlGenerator,
$this->appManager,
$this->imageManager,
@@ -164,9 +154,12 @@ class ThemingControllerTest extends TestCase {
['url', str_repeat('a', 501), 'The given web address is not a valid URL'],
['url', 'javascript:alert(1)', 'The given web address is not a valid URL'],
['slogan', str_repeat('a', 501), 'The given slogan is too long'],
['color', '0082C9', 'The given color is invalid'],
['color', '#0082Z9', 'The given color is invalid'],
['color', 'Nextcloud', 'The given color is invalid'],
['primary_color', '0082C9', 'The given color is invalid'],
['primary_color', '#0082Z9', 'The given color is invalid'],
['primary_color', 'Nextcloud', 'The given color is invalid'],
['background_color', '0082C9', 'The given color is invalid'],
['background_color', '#0082Z9', 'The given color is invalid'],
['background_color', 'Nextcloud', 'The given color is invalid'],
['imprintUrl', '0082C9', 'The given legal notice address is not a valid URL'],
['imprintUrl', '0082C9', 'The given legal notice address is not a valid URL'],
['imprintUrl', 'javascript:foo', 'The given legal notice address is not a valid URL'],

+ 6
- 1
apps/theming/tests/ImageManagerTest.php View File

@@ -28,6 +28,7 @@
namespace OCA\Theming\Tests;

use OCA\Theming\ImageManager;
use OCA\Theming\Service\BackgroundService;
use OCP\Files\IAppData;
use OCP\Files\NotFoundException;
use OCP\Files\SimpleFS\ISimpleFile;
@@ -57,6 +58,8 @@ class ImageManagerTest extends TestCase {
private $tempManager;
/** @var ISimpleFolder|MockObject */
private $rootFolder;
/** @var BackgroundService|MockObject */
private $backgroundService;

protected function setUp(): void {
parent::setUp();
@@ -67,13 +70,15 @@ class ImageManagerTest extends TestCase {
$this->logger = $this->createMock(LoggerInterface::class);
$this->tempManager = $this->createMock(ITempManager::class);
$this->rootFolder = $this->createMock(ISimpleFolder::class);
$this->backgroundService = $this->createMock(BackgroundService::class);
$this->imageManager = new ImageManager(
$this->config,
$this->appData,
$this->urlGenerator,
$this->cacheFactory,
$this->logger,
$this->tempManager
$this->tempManager,
$this->backgroundService,
);
$this->appData
->expects($this->any())

+ 10
- 4
apps/theming/tests/Settings/PersonalTest.php View File

@@ -30,6 +30,7 @@ namespace OCA\Theming\Tests\Settings;
use OCA\Theming\AppInfo\Application;
use OCA\Theming\ImageManager;
use OCA\Theming\ITheme;
use OCA\Theming\Service\BackgroundService;
use OCA\Theming\Service\ThemesService;
use OCA\Theming\Settings\Personal;
use OCA\Theming\Themes\DarkHighContrastTheme;
@@ -116,18 +117,23 @@ class PersonalTest extends TestCase {
->with('enforce_theme', '')
->willReturn($enforcedTheme);

$this->config->expects($this->once())
$this->config->expects($this->any())
->method('getUserValue')
->with('admin', 'core', 'apporder')
->willReturn('[]');
->willReturnMap([
['admin', 'core', 'apporder', '[]', '[]'],
['admin', 'theming', 'background_image', BackgroundService::BACKGROUND_DEFAULT],
]);

$this->appManager->expects($this->once())
->method('getDefaultAppForUser')
->willReturn('forcedapp');

$this->initialStateService->expects($this->exactly(4))
$this->initialStateService->expects($this->exactly(7))
->method('provideInitialState')
->withConsecutive(
['shippedBackgrounds', BackgroundService::SHIPPED_BACKGROUNDS],
['themingDefaults'],
['userBackgroundImage'],
['themes', $themesState],
['enforceTheme', $enforcedTheme],
['isUserThemingDisabled', false],

+ 8
- 0
apps/theming/tests/Themes/DarkHighContrastThemeTest.php View File

@@ -81,6 +81,14 @@ class DarkHighContrastThemeTest extends AccessibleThemeTestCase {
->expects($this->any())
->method('getDefaultColorPrimary')
->willReturn('#0082c9');
$this->themingDefaults
->expects($this->any())
->method('getColorBackground')
->willReturn('#0082c9');
$this->themingDefaults
->expects($this->any())
->method('getDefaultColorBackground')
->willReturn('#0082c9');

$this->themingDefaults
->expects($this->any())

+ 8
- 0
apps/theming/tests/Themes/DarkThemeTest.php View File

@@ -78,6 +78,14 @@ class DarkThemeTest extends AccessibleThemeTestCase {
->expects($this->any())
->method('getDefaultColorPrimary')
->willReturn('#0082c9');
$this->themingDefaults
->expects($this->any())
->method('getColorBackground')
->willReturn('#0082c9');
$this->themingDefaults
->expects($this->any())
->method('getDefaultColorBackground')
->willReturn('#0082c9');

$this->themingDefaults
->expects($this->any())

+ 14
- 2
apps/theming/tests/Themes/DefaultThemeTest.php View File

@@ -69,15 +69,27 @@ class DefaultThemeTest extends AccessibleThemeTestCase {
$this->imageManager
);

$defaultBackground = BackgroundService::SHIPPED_BACKGROUNDS[BackgroundService::DEFAULT_BACKGROUND_IMAGE];

$this->themingDefaults
->expects($this->any())
->method('getColorPrimary')
->willReturn('#0082c9');
->willReturn($defaultBackground['primary_color']);

$this->themingDefaults
->expects($this->any())
->method('getColorBackground')
->willReturn($defaultBackground['background_color']);

$this->themingDefaults
->expects($this->any())
->method('getDefaultColorPrimary')
->willReturn('#0082c9');
->willReturn($defaultBackground['primary_color']);

$this->themingDefaults
->expects($this->any())
->method('getDefaultColorBackground')
->willReturn($defaultBackground['background_color']);

$this->themingDefaults
->expects($this->any())

+ 10
- 0
apps/theming/tests/Themes/DyslexiaFontTest.php View File

@@ -94,6 +94,16 @@ class DyslexiaFontTest extends TestCase {
->method('getDefaultColorPrimary')
->willReturn('#0082c9');

$this->themingDefaults
->expects($this->any())
->method('getColorBackground')
->willReturn('#0082c9');

$this->themingDefaults
->expects($this->any())
->method('getDefaultColorBackground')
->willReturn('#0082c9');

$this->l10n
->expects($this->any())
->method('t')

+ 8
- 0
apps/theming/tests/Themes/HighContrastThemeTest.php View File

@@ -81,6 +81,14 @@ class HighContrastThemeTest extends AccessibleThemeTestCase {
->expects($this->any())
->method('getDefaultColorPrimary')
->willReturn('#0082c9');
$this->themingDefaults
->expects($this->any())
->method('getColorBackground')
->willReturn('#0082c9');
$this->themingDefaults
->expects($this->any())
->method('getDefaultColorBackground')
->willReturn('#0082c9');

$this->themingDefaults
->expects($this->any())

+ 65
- 101
apps/theming/tests/ThemingDefaultsTest.php View File

@@ -78,6 +78,8 @@ class ThemingDefaultsTest extends TestCase {
private $imageManager;
/** @var INavigationManager|\PHPUnit\Framework\MockObject\MockObject */
private $navigationManager;
/** @var BackgroundService|\PHPUnit\Framework\MockObject\MockObject */
private $backgroundService;

protected function setUp(): void {
parent::setUp();
@@ -91,6 +93,7 @@ class ThemingDefaultsTest extends TestCase {
$this->imageManager = $this->createMock(ImageManager::class);
$this->appManager = $this->createMock(IAppManager::class);
$this->navigationManager = $this->createMock(INavigationManager::class);
$this->backgroundService = $this->createMock(BackgroundService::class);
$this->defaults = new \OC_Defaults();
$this->urlGenerator
->expects($this->any())
@@ -105,7 +108,8 @@ class ThemingDefaultsTest extends TestCase {
$this->util,
$this->imageManager,
$this->appManager,
$this->navigationManager
$this->navigationManager,
$this->backgroundService,
);
}

@@ -428,7 +432,7 @@ class ThemingDefaultsTest extends TestCase {
->method('getAppValue')
->willReturnMap([
['theming', 'disable-user-theming', 'no', 'no'],
['theming', 'color', '', $this->defaults->getColorPrimary()],
['theming', 'primary_color', '', $this->defaults->getColorPrimary()],
]);

$this->assertEquals($this->defaults->getColorPrimary(), $this->template->getColorPrimary());
@@ -440,66 +444,57 @@ class ThemingDefaultsTest extends TestCase {
->method('getAppValue')
->willReturnMap([
['theming', 'disable-user-theming', 'no', 'no'],
['theming', 'color', '', '#fff'],
['theming', 'primary_color', '', '#fff'],
]);

$this->assertEquals('#fff', $this->template->getColorPrimary());
}

public function testGetColorPrimaryWithDefaultBackground() {
$user = $this->createMock(IUser::class);
$this->userSession->expects($this->any())
->method('getUser')
->willReturn($user);
$user->expects($this->any())
->method('getUID')
->willReturn('user');
$this->config
->expects($this->exactly(2))
->method('getAppValue')
->willReturnMap([
['theming', 'disable-user-theming', 'no', 'no'],
['theming', 'color', '', ''],
]);
$this->config
->expects($this->once())
->method('getUserValue')
->with('user', 'theming', 'background_color')
->willReturn('');

$this->assertEquals(BackgroundService::DEFAULT_COLOR, $this->template->getColorPrimary());
}

public function testGetColorPrimaryWithCustomBackground() {
$backgroundIndex = 2;
$background = array_values(BackgroundService::SHIPPED_BACKGROUNDS)[$backgroundIndex];

$user = $this->createMock(IUser::class);
$this->userSession->expects($this->any())
->method('getUser')
->willReturn($user);
$user->expects($this->any())
->method('getUID')
->willReturn('user');

$this->config
->expects($this->once())
->method('getUserValue')
->with('user', 'theming', 'background_color', '')
->willReturn($background['primary_color']);

$this->config
->expects($this->exactly(2))
->method('getAppValue')
->willReturnMap([
['theming', 'color', '', ''],
['theming', 'disable-user-theming', 'no', 'no'],
]);

$this->assertEquals($background['primary_color'], $this->template->getColorPrimary());
public function dataGetColorPrimary() {
return [
'with fallback default' => [
'disableTheming' => 'no',
'primaryColor' => '',
'userPrimaryColor' => '',
'expected' => BackgroundService::DEFAULT_COLOR,
],
'with custom admin primary' => [
'disableTheming' => 'no',
'primaryColor' => '#aaa',
'userPrimaryColor' => '',
'expected' => '#aaa',
],
'with custom invalid admin primary' => [
'disableTheming' => 'no',
'primaryColor' => 'invalid',
'userPrimaryColor' => '',
'expected' => BackgroundService::DEFAULT_COLOR,
],
'with custom invalid user primary' => [
'disableTheming' => 'no',
'primaryColor' => '',
'userPrimaryColor' => 'invalid-name',
'expected' => BackgroundService::DEFAULT_COLOR,
],
'with custom user primary' => [
'disableTheming' => 'no',
'primaryColor' => '',
'userPrimaryColor' => '#bbb',
'expected' => '#bbb',
],
'with disabled user theming primary' => [
'disableTheming' => 'yes',
'primaryColor' => '#aaa',
'userPrimaryColor' => '#bbb',
'expected' => '#aaa',
],
];
}

public function testGetColorPrimaryWithCustomBackgroundColor() {
/**
* @dataProvider dataGetColorPrimary
*/
public function testGetColorPrimary(string $disableTheming, string $primaryColor, string $userPrimaryColor, string $expected) {
$user = $this->createMock(IUser::class);
$this->userSession->expects($this->any())
->method('getUser')
@@ -507,46 +502,20 @@ class ThemingDefaultsTest extends TestCase {
$user->expects($this->any())
->method('getUID')
->willReturn('user');

$this->config
->expects($this->once())
->method('getUserValue')
->with('user', 'theming', 'background_color', '')
->willReturn('#fff');
$this->config
->expects($this->exactly(2))
->expects($this->any())
->method('getAppValue')
->willReturnMap([
['theming', 'color', '', ''],
['theming', 'disable-user-theming', 'no', 'no'],
['theming', 'disable-user-theming', 'no', $disableTheming],
['theming', 'primary_color', '', $primaryColor],
]);

$this->assertEquals('#fff', $this->template->getColorPrimary());
}

public function testGetColorPrimaryWithInvalidCustomBackgroundColor() {
$user = $this->createMock(IUser::class);
$this->userSession->expects($this->any())
->method('getUser')
->willReturn($user);
$user->expects($this->any())
->method('getUID')
->willReturn('user');

$this->config
->expects($this->once())
->expects($this->any())
->method('getUserValue')
->with('user', 'theming', 'background_color', '')
->willReturn('nextcloud');
$this->config
->expects($this->exactly(3))
->method('getAppValue')
->willReturnMap([
['theming', 'color', '', ''],
['theming', 'disable-user-theming', 'no', 'no'],
]);
->with('user', 'theming', 'primary_color', '')
->willReturn($userPrimaryColor);

$this->assertEquals($this->template->getDefaultColorPrimary(), $this->template->getColorPrimary());
$this->assertEquals($expected, $this->template->getColorPrimary());
}

public function testSet() {
@@ -646,27 +615,22 @@ class ThemingDefaultsTest extends TestCase {
$this->assertSame($this->defaults->getSlogan(), $this->template->undo('slogan'));
}

public function testUndoColor() {
public function testUndoPrimaryColor() {
$this->config
->expects($this->once())
->method('deleteAppValue')
->with('theming', 'color');
->with('theming', 'primary_color');
$this->config
->expects($this->exactly(2))
->expects($this->once())
->method('getAppValue')
->withConsecutive(
['theming', 'cachebuster', '0'],
['theming', 'color', null],
)->willReturnOnConsecutiveCalls(
'15',
$this->defaults->getColorPrimary(),
);
->with('theming', 'cachebuster', '0')
->willReturn('15');
$this->config
->expects($this->once())
->method('setAppValue')
->with('theming', 'cachebuster', 16);

$this->assertSame($this->defaults->getColorPrimary(), $this->template->undo('color'));
$this->assertSame($this->defaults->getColorPrimary(), $this->template->undo('primary_color'));
}

public function testUndoDefaultAction() {
@@ -764,8 +728,8 @@ class ThemingDefaultsTest extends TestCase {
['theming', 'backgroundMime', '', 'jpeg'],
['theming', 'logoheaderMime', '', 'jpeg'],
['theming', 'faviconMime', '', 'jpeg'],
['theming', 'color', '', $this->defaults->getColorPrimary()],
['theming', 'color', $this->defaults->getColorPrimary(), $this->defaults->getColorPrimary()],
['theming', 'primary_color', '', $this->defaults->getColorPrimary()],
['theming', 'primary_color', $this->defaults->getColorPrimary(), $this->defaults->getColorPrimary()],
]);

$this->util->expects($this->any())->method('invertTextColor')->with($this->defaults->getColorPrimary())->willReturn(false);

+ 1
- 1
core/css/apps.css
File diff suppressed because it is too large
View File


+ 1
- 1
core/css/apps.css.map
File diff suppressed because it is too large
View File


+ 4
- 4
core/css/apps.scss View File

@@ -29,15 +29,15 @@ html {
width: 100%;
height: 100%;
position: absolute;
// color-background-plain should always be defined. It is the primary user colour
// color-background-plain should always be defined.
background-color: var(--color-background-plain, var(--color-main-background));
}

body {
// color-background-plain should always be defined. It is the primary user colour
// color-background-plain should always be defined.
background-color: var(--color-background-plain, var(--color-main-background));
// user background, or plain colour and finally default admin background
background-image: var(--image-background, var(--image-background-plain, var(--image-background-default)));
// user background, or plain color
background-image: var(--image-background);
background-size: cover;
background-position: center;
position: fixed;

+ 1
- 1
core/css/guest.css
File diff suppressed because it is too large
View File


+ 1
- 1
core/css/guest.css.map View File

@@ -1 +1 @@
{"version":3,"sourceRoot":"","sources":["animations.scss","guest.scss"],"names":[],"mappings":"AAIA,0BACC,KACC,+BACA,uBAED,GACC,iCACA,0BAGF,kBACC,KACC,+BACA,uBAED,GACC,iCACA,0BCXF,wYACA,iBACA,2EACA,qBACA,mEACA,iDACA,kCACA,6DACA,6DACA,mBAEA,KACC,mBAEA,iBACA,kBACA,6NACA,wBACA,kBAEA,8FAMA,2JACG,4BACH,gBACA,YACA,cACA,gBAKA,cACC,gBAGD,qBACC,wBAGD,kEAEC,0BACA,8BAIF,GACC,kBACA,WAID,SAGC,iBAGD,GACC,eACA,mBACA,iBAED,GACC,eACA,cAID,KACC,aACA,sBACA,uBACA,mBAIA,cACC,wEACA,4BACA,wBACA,2BACA,YACA,aACA,cACA,kBACA,WAIF,SACC,WACA,gBACA,uBAID,KACC,kBACA,YACA,UAED,kBACC,gBAGD,4DAEC,WAED,uCACC,YACA,uBACA,SACA,UAGD,wBACC,SAGD,wBACC,iBACA,mBAED,sCACC,mBACA,gBAED,2CAEC,mBAED,qBACC,WAED,2CACC,WAED,sBACC,cACA,SAED,0BACC,mBAID,cACC,aAED,kBACC,sBAID,gBACC,aACA,mBACA,uBACA,iBACA,kBAID,2CACC,SACC,cAMF,gBACC,cAEA,6BACC,kBACA,WACA,mCACA,oBAKD,uMAGC,WAGD,oCACC,kBACA,SACA,WAMF,uDACC,6NAED,yFAKC,eACA,WACA,YACA,aACA,kBACA,wBAGD,gDAEI,iBAGJ,sCACI,2BAGJ,qGAOC,qBACA,WACA,eACA,aACA,8CACA,iBACA,wBACA,YACA,oBACA,eAGD,kCACC,kBACA,UACA,QAGD,yBACC,6DAED,wEAIC,YACA,iBACA,gCACA,YACA,oBACA,mBACA,cACA,eAED,gFACC,YACA,8BAED,YACC,YACA,YACA,sCAED,4FAIC,kBACA,gBACA,uBAID,iDAEC,SAGD,4MAMC,wCAID,8BACC,kBACA,cACA,SACA,UACA,WACA,gBAED,oCACC,iBAED,iGAEC,eAED,2CACC,WACA,qBACA,sBACA,WACA,eACA,sBACA,kBACA,YACA,WACA,2BAED,kDACC,kBAED,6IAEC,kBAED,0DACC,sBACA,kBAED,2DACC,iCACA,6BAED,mEACC,kBACA,sBAED,0DACC,0CACA,6BACA,+DAID,qBACC,qBACA,kBACA,UACA,YACA,0BACA,gBACA,WAED,eACC,iBACA,gBACA,kBAID,yBACC,kBACA,UACA,SACA,aACA,uBACA,WACA,qBACA,aAGD,2CACC,6BACA,UAED,qBACC,YACA,iBACA,WACA,cAED,gBACC,iBAID,8GAKC,kBAGD,uIAGC,mDAGD,iCAEC,YACA,sBAED,qBACC,aACA,kBACA,cACA,+BACA,gBACA,mBACA,gCAEA,kFAGC,sBAED,yBACC,WACA,YACA,eAED,yBACC,cAED,wBACC,SAED,uBACC,mBAGF,iBACC,YACA,cACA,cAED,mBACC,WACA,aACA,iBACA,oBACA,eAED,oBAEC,wBACA,YAED,sBACC,qBACA,aAKD,gBACC,kCAKD,uBACC,gBACA,mBAID,mBACC,kBACA,mBACA,SACA,aACA,yBACC,mBAED,yBACC,YACA,kBACA,eACA,yCACA,6BACA,eACA,qCACA,kBAED,iFAEC,mBACA,0CACA,6BAED,8BACC,aAMF,oCAGC,kBACA,iBAED,uBACC,6BAED,aACC,mBAED,gCACC,2BAED,4BACC,6BACA,wBAKD,sBACC,aACA,sBACA,gBACA,qBACA,mBACA,eACA,sBACA,yBACA,qBACA,iBAGA,gCACC,qBACA,YAGD,2BACC,cAGD,yCACC,mBAMF,WACC,aAED,sBACC,gBAED,OACC,iBACA,yBACA,sBACA,qBACA,iBAID,QACC,wBACA,kBAEA,iBACC,gBACA,gBACA,iBACA,kBAGD,0BACC,WAGD,+BACC,sBAGF,0BACC,gBACA,mBAED,sBACC,aACA,cACA,YAEA,wCACC,qBACA,sBAIF,sBACC,YACA,YACA,qBAKD,iBACC,0DAED,kBACC,2DAED,kBACC,2DAED,cACC,uDAED,oBACC,6DAED,sBACC,+DAKD,eACC,iBACA,iBACA,YACA,aAED,iCACC,4BACA,2BACA,eACA,gBAED,sGACC,kBACA,wCAED,0IACC,UACA,WACA,YACA,WACA,uBACA,kBACA,QACA,SACA,mBACA,6CACA,qCACA,gCACA,4BACA,wBAED,wTACI,uCAEJ,0IACC,sCACA,yBAED,wDACC,sCACA,sBAED,yDACC,YACA,WACA,qBAGD,gLACC,2CAED,wNACC,gDAED,gOACC,iDAED,wQACC,sDAED,0BACC,KACA,+BACA,uBAEA,GACA,iCACA,0BAGD,kBACC,KACA,+BACA,uBAEA,GACA,iCACA,0BAMA,0BACC,iBAGD,iBACC,iBACA,mBAEA,uBACC,SAMH,+BAEC,kBACA,cACA,aACA,UACA,WACA,gBAGD,QACC,kBAGD,UACC,8BACA,wCACA,wCACA,mCACA,cACA,aACA,gBAEA,kBACC,uDACA,mCAGD,gBACC,qDACA,iCAGD,kBACC,uDACA,mCAGD,qBACC,gBAGD,cACC,8CACA,gBACA,kBACA,mCAIF,iCAEC,gGAEA,6BACA,mDACA,QAlyByB,KAmyBzB,2CACA,4CACA,qBACA,sDACA,8CAIA,gBACC,cACA,gBAGD,oBACC,aAGD,gBACC,kBAIF,uBACC,+BACA,eACA,YAID,YACC,2BAGD,WACC,2BAGD,QACC","file":"guest.css"}
{"version":3,"sourceRoot":"","sources":["animations.scss","guest.scss"],"names":[],"mappings":"AAIA,0BACC,KACC,+BACA,uBAED,GACC,iCACA,0BAGF,kBACC,KACC,+BACA,uBAED,GACC,iCACA,0BCXF,wYACA,iBACA,2EACA,qBACA,mEACA,iDACA,kCACA,6DACA,6DACA,mBAEA,KACC,mBAEA,iBACA,kBACA,6NACA,kDACA,kBACA,wDAIA,2FACA,4BACA,gBACA,YACA,cACA,gBAKA,cACC,gBAGD,qBACC,wBAGD,kEAEC,0BACA,8BAIF,GACC,kBACA,WAID,SAGC,iBAGD,GACC,eACA,mBACA,iBAED,GACC,eACA,cAID,KACC,aACA,sBACA,uBACA,mBAIA,cACC,wEACA,4BACA,wBACA,2BACA,YACA,aACA,cACA,kBACA,WAIF,SACC,WACA,gBACA,uBAID,KACC,kBACA,YACA,UAED,kBACC,gBAGD,4DAEC,WAED,uCACC,YACA,uBACA,SACA,UAGD,wBACC,SAGD,wBACC,iBACA,mBAED,sCACC,mBACA,gBAED,2CAEC,mBAED,qBACC,WAED,2CACC,WAED,sBACC,cACA,SAED,0BACC,mBAID,cACC,aAED,kBACC,sBAID,gBACC,aACA,mBACA,uBACA,iBACA,kBAID,2CACC,SACC,cAMF,gBACC,cAEA,6BACC,kBACA,WACA,mCACA,oBAKD,uMAGC,WAGD,oCACC,kBACA,SACA,WAMF,uDACC,6NAED,yFAKC,eACA,WACA,YACA,aACA,kBACA,wBAGD,gDAEI,iBAGJ,sCACI,2BAGJ,qGAOC,qBACA,WACA,eACA,aACA,8CACA,iBACA,wBACA,YACA,oBACA,eAGD,kCACC,kBACA,UACA,QAGD,yBACC,6DAED,wEAIC,YACA,iBACA,gCACA,YACA,oBACA,mBACA,cACA,eAED,gFACC,YACA,8BAED,YACC,YACA,YACA,sCAED,4FAIC,kBACA,gBACA,uBAID,iDAEC,SAGD,4MAMC,wCAID,8BACC,kBACA,cACA,SACA,UACA,WACA,gBAED,oCACC,iBAED,iGAEC,eAED,2CACC,WACA,qBACA,sBACA,WACA,eACA,sBACA,kBACA,YACA,WACA,2BAED,kDACC,kBAED,6IAEC,kBAED,0DACC,sBACA,kBAED,2DACC,iCACA,6BAED,mEACC,kBACA,sBAED,0DACC,0CACA,6BACA,+DAID,qBACC,qBACA,kBACA,UACA,YACA,0BACA,gBACA,WAED,eACC,iBACA,gBACA,kBAID,yBACC,kBACA,UACA,SACA,aACA,uBACA,WACA,qBACA,aAGD,2CACC,6BACA,UAED,qBACC,YACA,iBACA,WACA,cAED,gBACC,iBAID,8GAKC,kBAGD,uIAGC,mDAGD,iCAEC,YACA,sBAED,qBACC,aACA,kBACA,cACA,+BACA,gBACA,mBACA,gCAEA,kFAGC,sBAED,yBACC,WACA,YACA,eAED,yBACC,cAED,wBACC,SAED,uBACC,mBAGF,iBACC,YACA,cACA,cAED,mBACC,WACA,aACA,iBACA,oBACA,eAED,oBAEC,wBACA,YAED,sBACC,qBACA,aAKD,gBACC,kCAKD,uBACC,gBACA,mBAID,mBACC,kBACA,mBACA,SACA,aACA,yBACC,mBAED,yBACC,YACA,kBACA,eACA,yCACA,6BACA,eACA,qCACA,kBAED,iFAEC,mBACA,0CACA,6BAED,8BACC,aAMF,oCAGC,kBACA,iBAED,uBACC,6BAED,aACC,mBAED,gCACC,2BAED,4BACC,6BACA,wBAKD,sBACC,aACA,sBACA,gBACA,qBACA,mBACA,eACA,sBACA,yBACA,qBACA,iBAGA,gCACC,qBACA,YAGD,2BACC,cAGD,yCACC,mBAMF,WACC,aAED,sBACC,gBAED,OACC,iBACA,yBACA,sBACA,qBACA,iBAID,QACC,wBACA,kBAEA,iBACC,gBACA,gBACA,iBACA,kBAGD,0BACC,WAGD,+BACC,sBAGF,0BACC,gBACA,mBAED,sBACC,aACA,cACA,YAEA,wCACC,qBACA,sBAIF,sBACC,YACA,YACA,qBAKD,iBACC,0DAED,kBACC,2DAED,kBACC,2DAED,cACC,uDAED,oBACC,6DAED,sBACC,+DAKD,eACC,iBACA,iBACA,YACA,aAED,iCACC,4BACA,2BACA,eACA,gBAED,sGACC,kBACA,wCAED,0IACC,UACA,WACA,YACA,WACA,uBACA,kBACA,QACA,SACA,mBACA,6CACA,qCACA,gCACA,4BACA,wBAED,wTACI,uCAEJ,0IACC,sCACA,yBAED,wDACC,sCACA,sBAED,yDACC,YACA,WACA,qBAGD,gLACC,2CAED,wNACC,gDAED,gOACC,iDAED,wQACC,sDAED,0BACC,KACA,+BACA,uBAEA,GACA,iCACA,0BAGD,kBACC,KACA,+BACA,uBAEA,GACA,iCACA,0BAMA,0BACC,iBAGD,iBACC,iBACA,mBAEA,uBACC,SAMH,+BAEC,kBACA,cACA,aACA,UACA,WACA,gBAGD,QACC,kBAGD,UACC,8BACA,wCACA,wCACA,mCACA,cACA,aACA,gBAEA,kBACC,uDACA,mCAGD,gBACC,qDACA,iCAGD,kBACC,uDACA,mCAGD,qBACC,gBAGD,cACC,8CACA,gBACA,kBACA,mCAIF,iCAEC,gGAEA,6BACA,mDACA,QA/xByB,KAgyBzB,2CACA,4CACA,qBACA,sDACA,8CAIA,gBACC,cACA,gBAGD,oBACC,aAGD,gBACC,kBAIF,uBACC,+BACA,eACA,YAID,YACC,2BAGD,WACC,2BAGD,QACC","file":"guest.css"}

+ 7
- 10
core/css/guest.scss View File

@@ -25,17 +25,14 @@ body {
font-size: .875em;
line-height: 1.6em;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Oxygen-Sans, Cantarell, Ubuntu, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
color: var(--color-text);
color: var(--color-background-plain-text, #ffffff);
text-align: center;
/* As guest, there is no color-background-plain */
background-color: var(--color-background-plain, var(--color-primary-element-default, #0082c9));
/* As guest, there is no user background (--image-background)
1. User background if logged in ('no' if removed, that way the variable is _defined_)
2. Empty background if enabled ('yes' is used, that way the variable is _defined_)
3. Else default background
4. Finally default gradient (should not happened, the background is always defined anyway) */
background-image: var(--image-background, var(--image-background-plain, var(--image-background-default, linear-gradient(40deg, #0082c9 0%, #30b6ff 100%))));
background-attachment: fixed;
background-color: var(--color-background-plain, #0082c9);
/*
User background if logged in ('no' if removed, that way the variable is _defined_)
Fallback to default gradient (should not happened, the background is always defined anyway) */
background-image: var(--image-background, linear-gradient(40deg, #0082c9 0%, #30b6ff 100%));
background-attachment: fixed;
min-height: 100%; /* fix sticky footer */
height: auto;
overflow: auto;

+ 1
- 1
core/css/header.css
File diff suppressed because it is too large
View File


+ 1
- 1
core/css/header.css.map View File

@@ -1 +1 @@
{"version":3,"sourceRoot":"","sources":["header.scss","variables.scss"],"names":[],"mappings":"AAQA,mBAEC,yBACA,sBACA,qBACA,6PACC,aAGD,+QACC,YACA,kBACA,2BACA,WACA,WACA,kBACA,2CACA,SACA,UAGD,gLACC,WAIA,kPACC,WAGD,+HACC,SAOH,+DAGC,oBACA,kBACA,MACA,WACA,aACA,OCmCe,KDlCf,sBACA,8BAID,WACC,cACA,kBACA,kBACA,wBACA,sBACA,UACA,mBACA,aACA,eACA,gBACA,WAEA,mCACC,UAaD,gCACC,8CACA,sDACA,yCACA,sBACA,aACA,kBACA,gBAfD,gBACA,oCAgBC,UACA,ICRc,KDSd,SACA,gBAEA,kDACC,aAID,sCACC,gCACA,iDACA,YACA,YACA,SACA,QACA,kBACA,oBACA,WAGD,uEAEC,iCAzCF,gBACA,oCA4CA,cACC,oBACA,yFACA,4BACA,wBACA,2BACA,WACA,kBACA,UACA,QACA,WAEA,gFAGD,kCACC,aACA,mBACA,cAGD,sFAEC,oBACA,mBAGD,0CACC,SACA,mBACA,YAGD,4CACC,yBACA,cAOC,mGACC,gDAID,sFACC,gDAGF,qDAEC,YACA,kBACA,6EACC,aACA,uBACA,mBACA,MC9FY,KD+FZ,YACA,eACA,YACA,UACA,aAEA,yFACC,UAGD,yGACC,aASL,0CACC,YAKD,gBACC,wCACA,eACA,iBACA,SACA,UACA,kBACA,gBACA,uBAEA,cAGD,aACC,aACA,sBACA,gBAGD,cACC,gBACA,uBAGD,kBACC,wCACA,kBACA,gBACA,eACA,iBACA,gBACA,uBAID,cACC,kBACA,gBACA,aACA,WACA,SACA,aACA,aACA,eACA,SAEA,2BACC,ICxKc,KD+Kf,gDACC,mBACA,eAED,gJAEC,qBACA,YACA","file":"header.css"}
{"version":3,"sourceRoot":"","sources":["header.scss","variables.scss"],"names":[],"mappings":"AAQA,mBAEC,yBACA,sBACA,qBACA,6PACC,aAGD,+QACC,YACA,kBACA,2BACA,WACA,WACA,kBACA,oDACA,SACA,UAGD,gLACC,WAIA,kPACC,WAGD,+HACC,SAOH,+DAGC,oBACA,kBACA,MACA,WACA,aACA,OCmCe,KDlCf,sBACA,8BAID,WACC,cACA,kBACA,kBACA,wBACA,sBACA,UACA,mBACA,aACA,eACA,gBACA,WAEA,mCACC,UAaD,gCACC,8CACA,sDACA,yCACA,sBACA,aACA,kBACA,gBAfD,gBACA,oCAgBC,UACA,ICRc,KDSd,SACA,gBAEA,kDACC,aAID,sCACC,gCACA,iDACA,YACA,YACA,SACA,QACA,kBACA,oBACA,WAGD,uEAEC,iCAzCF,gBACA,oCA4CA,cACC,oBACA,yFACA,4BACA,wBACA,2BACA,WACA,kBACA,UACA,QACA,WAEA,gFAGD,kCACC,aACA,mBACA,cAGD,sFAEC,oBACA,mBAGD,0CACC,SACA,mBACA,YAGD,4CACC,yBACA,cAKA,gDACC,gDAED,qDAEC,YACA,kBACA,6EACC,aACA,uBACA,mBACA,MCtFY,KDuFZ,YACA,eACA,YACA,UACA,aAEA,yFACC,UAGD,yGACC,aASL,0CACC,YAKD,gBACC,wCACA,eACA,iBACA,SACA,UACA,kBACA,gBACA,uBAEA,cAGD,aACC,aACA,sBACA,gBAGD,cACC,gBACA,uBAGD,kBACC,wCACA,kBACA,gBACA,eACA,iBACA,gBACA,uBAID,cACC,kBACA,gBACA,aACA,WACA,SACA,aACA,aACA,eACA,SAEA,2BACC,IChKc,KDuKf,gDACC,mBACA,eAED,gJAEC,qBACA,YACA","file":"header.css"}

+ 3
- 11
core/css/header.scss View File

@@ -22,7 +22,7 @@
width: 12px;
height: 2px;
border-radius: 3px;
background-color: var(--color-primary-text);
background-color: var(--color-background-plain-text);
left: 50%;
opacity: 1;
}
@@ -162,16 +162,8 @@

/* Right header standard */
.header-right {
> .header-menu:not(.user-menu):not(.unified-search-menu) {
// For general
> .header-menu__trigger {
filter: var(--background-image-invert-if-bright);
}

// For assistant button
> .trigger {
filter: var(--background-image-invert-if-bright);
}
> .header-menu__trigger img {
filter: var(--background-image-invert-if-bright);
}
> div,
> form {

+ 1
- 1
core/css/server.css
File diff suppressed because it is too large
View File


+ 1
- 1
core/css/server.css.map
File diff suppressed because it is too large
View File


+ 7
- 7
core/src/components/AppMenu.vue View File

@@ -144,7 +144,7 @@ $header-icon-size: 20px;
width: 12px;
height: 5px;
border-radius: 3px;
background-color: var(--color-primary-text);
background-color: var(--color-background-plain-text);
left: 50%;
bottom: 6px;
display: block;
@@ -161,8 +161,8 @@ $header-icon-size: 20px;
width: calc(100% - 4px);
height: calc(100% - 4px);
margin: 2px;
// this is shown directly on the background which has `color-primary`, so we need `color-primary-text`
color: var(--color-primary-text);
// this is shown directly on the background
color: var(--color-background-plain-text);
position: relative;
}

@@ -179,8 +179,8 @@ $header-icon-size: 20px;
opacity: 0;
position: absolute;
font-size: 12px;
// this is shown directly on the background which has `color-primary`, so we need `color-primary-text`
color: var(--color-primary-text);
// this is shown directly on the background
color: var(--color-background-plain-text);
text-align: center;
left: 50%;
top: 45%;
@@ -238,7 +238,7 @@ $header-icon-size: 20px;

/* Remove all background and align text color if not expanded */
&:not([aria-expanded="true"]) {
color: var(--color-primary-element-text);
color: var(--color-background-plain-text);

&:hover {
opacity: 1;
@@ -278,7 +278,7 @@ $header-icon-size: 20px;
content: "";
width: 8px;
height: 8px;
background-color: var(--color-primary-element-text);
background-color: var(--color-background-plain-text);
border-radius: 50%;
position: absolute;
display: block;

+ 5
- 1
core/src/views/ContactsMenu.vue View File

@@ -9,7 +9,7 @@
:aria-label="t('core', 'Search contacts')"
@open="handleOpen">
<template #trigger>
<Contacts :size="20" />
<Contacts class="contactsmenu__trigger-icon" :size="20" />
</template>
<div class="contactsmenu__menu">
<div class="contactsmenu__menu__input-wrapper">
@@ -171,6 +171,10 @@ export default {
.contactsmenu {
overflow-y: hidden;

&__trigger-icon {
color: var(--color-background-plain-text) !important;
}

&__menu {
display: flex;
flex-direction: column;

+ 5
- 2
core/src/views/LegacyUnifiedSearch.vue View File

@@ -12,8 +12,7 @@
@close="onClose">
<!-- Header icon -->
<template #trigger>
<Magnify class="unified-search__trigger"
:size="22/* fit better next to other 20px icons */" />
<Magnify class="unified-search__trigger-icon" :size="20" />
</template>

<!-- Search form & filters wrapper -->
@@ -706,6 +705,10 @@ $input-height: 34px;
$input-padding: 10px;

.unified-search {
&__trigger-icon {
color: var(--color-background-plain-text) !important;
}

&__input-wrapper {
position: sticky;
// above search results

+ 1
- 1
core/src/views/UnifiedSearch.vue View File

@@ -71,7 +71,7 @@ export default {

&-icon {
// ensure the icon has the correct color
color: var(--color-primary-text) !important;
color: var(--color-background-plain-text) !important;
}
}
}

+ 236
- 100
cypress/e2e/theming/admin-settings.cy.ts View File

@@ -21,9 +21,15 @@
*/
/* eslint-disable n/no-unpublished-import */
import { User } from '@nextcloud/cypress'
import { colord } from 'colord'

import { defaultPrimary, defaultBackground, pickRandomColor, validateBodyThemingCss, validateUserThemingDefaultCss } from './themingUtils'
import {
defaultPrimary,
defaultBackground,
pickRandomColor,
validateBodyThemingCss,
validateUserThemingDefaultCss,
expectBackgroundColor,
} from './themingUtils'

const admin = new User('admin', 'admin')

@@ -36,15 +42,24 @@ describe('Admin theming settings visibility check', function() {

it('See the admin theming section', function() {
cy.visit('/settings/admin/theming')
cy.get('[data-admin-theming-settings]').should('exist').scrollIntoView()
cy.get('[data-admin-theming-settings]')
.should('exist')
.scrollIntoView()
cy.get('[data-admin-theming-settings]').should('be.visible')
})

it('See the default settings', function() {
cy.get('[data-admin-theming-setting-primary-color-picker]').should('exist')
cy.get('[data-admin-theming-setting-primary-color-reset]').should('not.exist')
cy.get('[data-admin-theming-setting-color-picker]').should('exist')
cy.get('[data-admin-theming-setting-file-reset]').should('not.exist')
cy.get('[data-admin-theming-setting-file-remove]').should('be.visible')
cy.get('[data-admin-theming-setting-file-remove]').should('exist')

cy.get(
'[data-admin-theming-setting-primary-color] [data-admin-theming-setting-color]',
).then(($el) => expectBackgroundColor($el, defaultPrimary))

cy.get(
'[data-admin-theming-setting-background-color] [data-admin-theming-setting-color]',
).then(($el) => expectBackgroundColor($el, defaultPrimary))
})
})

@@ -59,24 +74,42 @@ describe('Change the primary color and reset it', function() {

it('See the admin theming section', function() {
cy.visit('/settings/admin/theming')
cy.get('[data-admin-theming-settings]').should('exist').scrollIntoView()
cy.get('[data-admin-theming-settings]')
.should('exist')
.scrollIntoView()
cy.get('[data-admin-theming-settings]').should('be.visible')
})

it('Change the primary color', function() {
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor')

pickRandomColor().then(color => { selectedColor = color })
pickRandomColor('[data-admin-theming-setting-primary-color]').then(
(color) => {
selectedColor = color
},
)

cy.wait('@setColor')
cy.waitUntil(() => validateBodyThemingCss(selectedColor, defaultBackground))
cy.waitUntil(() =>
validateBodyThemingCss(
selectedColor,
defaultBackground,
defaultPrimary,
),
)
})

it('Screenshot the login page and validate login page', function() {
cy.logout()
cy.visit('/')

cy.waitUntil(() => validateBodyThemingCss(selectedColor, defaultBackground))
cy.waitUntil(() =>
validateBodyThemingCss(
selectedColor,
defaultBackground,
defaultPrimary,
),
)
cy.screenshot()
})

@@ -98,21 +131,29 @@ describe('Remove the default background and restore it', function() {

it('See the admin theming section', function() {
cy.visit('/settings/admin/theming')
cy.get('[data-admin-theming-settings]').should('exist').scrollIntoView()
cy.get('[data-admin-theming-settings]')
.should('exist')
.scrollIntoView()
cy.get('[data-admin-theming-settings]').should('be.visible')
})

it('Remove the default background', function() {
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('removeBackground')
cy.intercept('*/apps/theming/ajax/updateStylesheet').as(
'removeBackground',
)

cy.get('[data-admin-theming-setting-file-remove]').click()

cy.wait('@removeBackground')
cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, null))
cy.waitUntil(() => cy.window().then((win) => {
const backgroundPlain = getComputedStyle(win.document.body).getPropertyValue('--image-background-plain')
return backgroundPlain !== ''
}))
cy.waitUntil(() =>
cy.window().then((win) => {
const backgroundPlain = getComputedStyle(
win.document.body,
).getPropertyValue('--image-background')
return backgroundPlain !== ''
}),
)
})

it('Screenshot the login page and validate login page', function() {
@@ -132,7 +173,7 @@ describe('Remove the default background and restore it', function() {
})
})

describe('Remove the default background with a custom primary color', function() {
describe('Remove the default background with a custom background color', function() {
let selectedColor = ''

before(function() {
@@ -143,23 +184,40 @@ describe('Remove the default background with a custom primary color', function()

it('See the admin theming section', function() {
cy.visit('/settings/admin/theming')
cy.get('[data-admin-theming-settings]').should('exist').scrollIntoView()
cy.get('[data-admin-theming-settings]')
.should('exist')
.scrollIntoView()
cy.get('[data-admin-theming-settings]').should('be.visible')
})

it('Change the primary color', function() {
it('Change the background color', function() {
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor')

pickRandomColor().then(color => { selectedColor = color })
pickRandomColor('[data-admin-theming-setting-background-color]').then(
(color) => {
selectedColor = color
},
)

cy.wait('@setColor')
cy.waitUntil(() => validateBodyThemingCss(selectedColor, defaultBackground))
cy.waitUntil(() =>
validateBodyThemingCss(
defaultPrimary,
defaultBackground,
selectedColor,
),
)
})

it('Remove the default background', function() {
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('removeBackground')
cy.intercept('*/apps/theming/ajax/updateStylesheet').as(
'removeBackground',
)

cy.get('[data-admin-theming-setting-file-remove]').click()
cy.get('[data-admin-theming-setting-file-remove]').scrollIntoView()
cy.get('[data-admin-theming-setting-file-remove]').click({
force: true,
})

cy.wait('@removeBackground')
})
@@ -168,7 +226,9 @@ describe('Remove the default background with a custom primary color', function()
cy.logout()
cy.visit('/')

cy.waitUntil(() => validateBodyThemingCss(selectedColor, null))
cy.waitUntil(() =>
validateBodyThemingCss(defaultPrimary, null, selectedColor),
)
cy.screenshot()
})

@@ -182,6 +242,8 @@ describe('Remove the default background with a custom primary color', function()
})

describe('Remove the default background with a bright color', function() {
let selectedColor = ''

before(function() {
// Just in case previous test failed
cy.resetAdminTheming()
@@ -191,37 +253,51 @@ describe('Remove the default background with a bright color', function() {

it('See the admin theming section', function() {
cy.visit('/settings/admin/theming')
cy.get('[data-admin-theming-settings]').should('exist').scrollIntoView()
cy.get('[data-admin-theming-settings]')
.should('exist')
.scrollIntoView()
cy.get('[data-admin-theming-settings]').should('be.visible')
})

it('Remove the default background', function() {
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('removeBackground')
cy.intercept('*/apps/theming/ajax/updateStylesheet').as(
'removeBackground',
)

cy.get('[data-admin-theming-setting-file-remove]').click()

cy.wait('@removeBackground')
})

it('Change the primary color', function() {
it('Change the background color', function() {
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor')

// Pick one of the bright color preset
cy.get('[data-admin-theming-setting-primary-color-picker]').click()
cy.get('.color-picker__simple-color-circle:eq(4)').click()
pickRandomColor(
'[data-admin-theming-setting-background-color]',
4,
).then((color) => {
selectedColor = color
})

cy.wait('@setColor')
cy.waitUntil(() => validateBodyThemingCss('#ddcb55', null))
cy.waitUntil(() =>
validateBodyThemingCss(defaultPrimary, null, selectedColor),
)
})

it('See the header being inverted', function() {
cy.waitUntil(() => cy.window().then((win) => {
const firstEntry = win.document.querySelector('.app-menu-main li img')
if (!firstEntry) {
return false
}
return getComputedStyle(firstEntry).filter === 'invert(1)'
}))
cy.waitUntil(() =>
cy.window().then((win) => {
const firstEntry = win.document.querySelector(
'.app-menu-main li img',
)
if (!firstEntry) {
return false
}
return getComputedStyle(firstEntry).filter === 'invert(1)'
}),
)
})
})

@@ -238,7 +314,9 @@ describe('Change the login fields then reset them', function() {

it('See the admin theming section', function() {
cy.visit('/settings/admin/theming')
cy.get('[data-admin-theming-settings]').should('exist').scrollIntoView()
cy.get('[data-admin-theming-settings]')
.should('exist')
.scrollIntoView()
cy.get('[data-admin-theming-settings]').should('be.visible')
})

@@ -246,42 +324,54 @@ describe('Change the login fields then reset them', function() {
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('updateFields')

// Name
cy.get('[data-admin-theming-setting-field="name"] input[type="text"]')
.scrollIntoView()
cy.get('[data-admin-theming-setting-field="name"] input[type="text"]')
.type(`{selectall}${name}{enter}`)
cy.get(
'[data-admin-theming-setting-field="name"] input[type="text"]',
).scrollIntoView()
cy.get(
'[data-admin-theming-setting-field="name"] input[type="text"]',
).type(`{selectall}${name}{enter}`)
cy.wait('@updateFields')

// Url
cy.get('[data-admin-theming-setting-field="url"] input[type="url"]')
.scrollIntoView()
cy.get('[data-admin-theming-setting-field="url"] input[type="url"]')
.type(`{selectall}${url}{enter}`)
cy.get(
'[data-admin-theming-setting-field="url"] input[type="url"]',
).scrollIntoView()
cy.get(
'[data-admin-theming-setting-field="url"] input[type="url"]',
).type(`{selectall}${url}{enter}`)
cy.wait('@updateFields')

// Slogan
cy.get('[data-admin-theming-setting-field="slogan"] input[type="text"]')
.scrollIntoView()
cy.get('[data-admin-theming-setting-field="slogan"] input[type="text"]')
.type(`{selectall}${slogan}{enter}`)
cy.get(
'[data-admin-theming-setting-field="slogan"] input[type="text"]',
).scrollIntoView()
cy.get(
'[data-admin-theming-setting-field="slogan"] input[type="text"]',
).type(`{selectall}${slogan}{enter}`)
cy.wait('@updateFields')
})

it('Ensure undo button presence', function() {
cy.get('[data-admin-theming-setting-field="name"] .input-field__trailing-button')
.scrollIntoView()
cy.get('[data-admin-theming-setting-field="name"] .input-field__trailing-button')
.should('be.visible')

cy.get('[data-admin-theming-setting-field="url"] .input-field__trailing-button')
.scrollIntoView()
cy.get('[data-admin-theming-setting-field="url"] .input-field__trailing-button')
.should('be.visible')

cy.get('[data-admin-theming-setting-field="slogan"] .input-field__trailing-button')
.scrollIntoView()
cy.get('[data-admin-theming-setting-field="slogan"] .input-field__trailing-button')
.should('be.visible')
cy.get(
'[data-admin-theming-setting-field="name"] .input-field__trailing-button',
).scrollIntoView()
cy.get(
'[data-admin-theming-setting-field="name"] .input-field__trailing-button',
).should('be.visible')

cy.get(
'[data-admin-theming-setting-field="url"] .input-field__trailing-button',
).scrollIntoView()
cy.get(
'[data-admin-theming-setting-field="url"] .input-field__trailing-button',
).should('be.visible')

cy.get(
'[data-admin-theming-setting-field="slogan"] .input-field__trailing-button',
).scrollIntoView()
cy.get(
'[data-admin-theming-setting-field="slogan"] .input-field__trailing-button',
).should('be.visible')
})

it('Validate login screen changes', function() {
@@ -317,19 +407,29 @@ describe('Disable user theming and enable it back', function() {

it('See the admin theming section', function() {
cy.visit('/settings/admin/theming')
cy.get('[data-admin-theming-settings]').should('exist').scrollIntoView()
cy.get('[data-admin-theming-settings]')
.should('exist')
.scrollIntoView()
cy.get('[data-admin-theming-settings]').should('be.visible')
})

it('Disable user background theming', function() {
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('disableUserTheming')

cy.get('[data-admin-theming-setting-disable-user-theming]')
.scrollIntoView()
cy.get('[data-admin-theming-setting-disable-user-theming]')
.should('be.visible')
cy.get('[data-admin-theming-setting-disable-user-theming] input[type="checkbox"]').check({ force: true })
cy.get('[data-admin-theming-setting-disable-user-theming] input[type="checkbox"]').should('be.checked')
cy.intercept('*/apps/theming/ajax/updateStylesheet').as(
'disableUserTheming',
)

cy.get(
'[data-admin-theming-setting-disable-user-theming]',
).scrollIntoView()
cy.get('[data-admin-theming-setting-disable-user-theming]').should(
'be.visible',
)
cy.get(
'[data-admin-theming-setting-disable-user-theming] input[type="checkbox"]',
).check({ force: true })
cy.get(
'[data-admin-theming-setting-disable-user-theming] input[type="checkbox"]',
).should('be.checked')

cy.wait('@disableUserTheming')
})
@@ -343,8 +443,9 @@ describe('Disable user theming and enable it back', function() {

it('User cannot not change background settings', function() {
cy.visit('/settings/user/theming')
cy.get('[data-user-theming-background-disabled]').scrollIntoView()
cy.get('[data-user-theming-background-disabled]').should('be.visible')
cy.contains(
'Customization has been disabled by your administrator',
).should('exist')
})
})

@@ -363,40 +464,60 @@ describe('The user default background settings reflect the admin theming setting

it('See the admin theming section', function() {
cy.visit('/settings/admin/theming')
cy.get('[data-admin-theming-settings]').should('exist').scrollIntoView()
cy.get('[data-admin-theming-settings]')
.should('exist')
.scrollIntoView()
cy.get('[data-admin-theming-settings]').should('be.visible')
})

it('Change the primary color', function() {
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor')

pickRandomColor().then(color => { selectedColor = color })

cy.wait('@setColor')
cy.waitUntil(() => cy.window().then(($window) => {
const primary = $window.getComputedStyle($window.document.body).getPropertyValue('--color-primary-default')
return colord(primary).isEqual(selectedColor)
}))
})

it('Change the default background', function() {
cy.intercept('*/apps/theming/ajax/uploadImage').as('setBackground')

cy.fixture('image.jpg', null).as('background')
cy.get('[data-admin-theming-setting-file="background"] input[type="file"]').selectFile('@background', { force: true })
cy.get(
'[data-admin-theming-setting-file="background"] input[type="file"]',
).selectFile('@background', { force: true })

cy.wait('@setBackground')
cy.waitUntil(() => cy.window().then((win) => {
const currentBackgroundDefault = getComputedStyle(win.document.body).getPropertyValue('--image-background-default')
return currentBackgroundDefault.includes('/apps/theming/image/background?v=')
}))
cy.waitUntil(() =>
validateBodyThemingCss(
defaultPrimary,
'/apps/theming/image/background?v=',
null,
),
)
})

it('Change the background color', function() {
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor')

pickRandomColor('[data-admin-theming-setting-background-color]').then(
(color) => {
selectedColor = color
},
)

cy.wait('@setColor')
cy.waitUntil(() =>
validateBodyThemingCss(
defaultPrimary,
'/apps/theming/image/background?v=',
selectedColor,
),
)
})

it('Login page should match admin theming settings', function() {
cy.logout()
cy.visit('/')

cy.waitUntil(() => validateBodyThemingCss(selectedColor, '/apps/theming/image/background?v='))
cy.waitUntil(() =>
validateBodyThemingCss(
defaultPrimary,
'/apps/theming/image/background?v=',
selectedColor,
),
)
})

it('Login as user', function() {
@@ -413,9 +534,17 @@ describe('The user default background settings reflect the admin theming setting

it('Default user background settings should match admin theming settings', function() {
cy.get('[data-user-theming-background-default]').should('be.visible')
cy.get('[data-user-theming-background-default]').should('have.class', 'background--active')

cy.waitUntil(() => validateUserThemingDefaultCss(selectedColor, '/apps/theming/image/background?v='))
cy.get('[data-user-theming-background-default]').should(
'have.class',
'background--active',
)

cy.waitUntil(() =>
validateUserThemingDefaultCss(
selectedColor,
'/apps/theming/image/background?v=',
),
)
})
})

@@ -432,12 +561,16 @@ describe('The user default background settings reflect the admin theming setting

it('See the admin theming section', function() {
cy.visit('/settings/admin/theming')
cy.get('[data-admin-theming-settings]').should('exist').scrollIntoView()
cy.get('[data-admin-theming-settings]')
.should('exist')
.scrollIntoView()
cy.get('[data-admin-theming-settings]').should('be.visible')
})

it('Remove the default background', function() {
cy.intercept('*/apps/theming/ajax/updateStylesheet').as('removeBackground')
cy.intercept('*/apps/theming/ajax/updateStylesheet').as(
'removeBackground',
)

cy.get('[data-admin-theming-setting-file-remove]').click()

@@ -466,7 +599,10 @@ describe('The user default background settings reflect the admin theming setting

it('Default user background settings should match admin theming settings', function() {
cy.get('[data-user-theming-background-default]').should('be.visible')
cy.get('[data-user-theming-background-default]').should('have.class', 'background--active')
cy.get('[data-user-theming-background-default]').should(
'have.class',
'background--active',
)

cy.waitUntil(() => validateUserThemingDefaultCss(defaultPrimary, null))
})

+ 48
- 22
cypress/e2e/theming/themingUtils.ts View File

@@ -21,29 +21,54 @@
*/
import { colord } from 'colord'

const defaultNextcloudBlue = '#0082c9'
export const defaultPrimary = '#00679e'
export const defaultBackground = 'kamil-porembinski-clouds.jpg'

/**
* Check if a CSS variable is set to a specific color
* @param variable Variable to check
* @param expectedColor Color that is expected
*/
export function validateCSSVariable(variable: string, expectedColor: string) {
const value = window.getComputedStyle(Cypress.$('body').get(0)).getPropertyValue(variable)
console.debug(`${variable}, is: ${colord(value).toHex()} expected: ${expectedColor}`)
return colord(value).isEqual(expectedColor)
}

/**
* Validate the current page body css variables
*
* @param {string} expectedColor the expected color
* @param {string} expectedColor the expected primary color
* @param {string|null} expectedBackground the expected background
* @param {string|null} expectedBackgroundColor the expected background color (null to ignore)
*/
export const validateBodyThemingCss = function(expectedColor = defaultPrimary, expectedBackground: string|null = defaultBackground) {
export function validateBodyThemingCss(expectedColor = defaultPrimary, expectedBackground: string|null = defaultBackground, expectedBackgroundColor: string|null = defaultPrimary) {
// We must use `Cypress.$` here as any assertions (get is an assertion) is not allowed in wait-until's check function, see documentation
const guestBackgroundColor = Cypress.$('body').css('background-color')
const guestBackgroundImage = Cypress.$('body').css('background-image')

const isValidBackgroundColor = colord(guestBackgroundColor).isEqual(expectedColor)
const isValidBackgroundColor = expectedBackgroundColor === null || colord(guestBackgroundColor).isEqual(expectedBackgroundColor)
const isValidBackgroundImage = !expectedBackground
? guestBackgroundImage === 'none'
: guestBackgroundImage.includes(expectedBackground)

console.debug({ guestBackgroundColor: colord(guestBackgroundColor).toHex(), guestBackgroundImage, expectedColor, expectedBackground, isValidBackgroundColor, isValidBackgroundImage })
console.debug({
isValidBackgroundColor,
isValidBackgroundImage,
guestBackgroundColor: colord(guestBackgroundColor).toHex(),
guestBackgroundImage,
})

return isValidBackgroundColor && isValidBackgroundImage && validateCSSVariable('--color-primary', expectedColor)
}

return isValidBackgroundColor && isValidBackgroundImage
/**
* Check background color of element
* @param element JQuery element to check
* @param color expected color
*/
export function expectBackgroundColor(element: JQuery<HTMLElement>, color: string) {
expect(colord(element.css('background-color')).toHex()).equal(colord(color).toHex())
}

/**
@@ -58,28 +83,28 @@ export const validateUserThemingDefaultCss = function(expectedColor = defaultPri
return false
}

const defaultOptionBackground = defaultSelectButton.css('background-image')
const colorPickerOptionColor = defaultSelectButton.css('background-color')
const isNextcloudBlue = colord(colorPickerOptionColor).isEqual('#0082c9')
const backgroundImage = defaultSelectButton.css('background-image')
const backgroundColor = defaultSelectButton.css('background-color')

const isValidBackgroundImage = !expectedBackground
? defaultOptionBackground === 'none'
: defaultOptionBackground.includes(expectedBackground)

console.debug({ colorPickerOptionColor: colord(colorPickerOptionColor).toHex(), expectedColor, isValidBackgroundImage, isNextcloudBlue })
? (backgroundImage === 'none' || Cypress.$('body').css('background-image') === 'none')
: backgroundImage.includes(expectedBackground)

console.debug({
colorPickerOptionColor: colord(backgroundColor).toHex(),
expectedColor,
isValidBackgroundImage,
backgroundImage,
})

return isValidBackgroundImage && (
colord(colorPickerOptionColor).isEqual(expectedColor)
// we replace nextcloud blue with the the default rpimary (apps/theming/lib/Themes/DefaultTheme.php line 76)
|| (isNextcloudBlue && colord(expectedColor).isEqual(defaultPrimary))
)
return isValidBackgroundImage && colord(backgroundColor).isEqual(expectedColor)
}

export const pickRandomColor = function(): Cypress.Chainable<string> {
export const pickRandomColor = function(context: string, index?: number): Cypress.Chainable<string> {
// Pick one of the first 8 options
const randColour = Math.floor(Math.random() * 8)
const randColour = index ?? Math.floor(Math.random() * 8)

const colorPreviewSelector = '[data-user-theming-background-color],[data-admin-theming-setting-primary-color]'
const colorPreviewSelector = `${context} [data-admin-theming-setting-color]`

let oldColor = ''
cy.get(colorPreviewSelector).then(($el) => {
@@ -87,7 +112,8 @@ export const pickRandomColor = function(): Cypress.Chainable<string> {
})

// Open picker
cy.contains('button', 'Change color').click()
cy.get(`${context} [data-admin-theming-setting-color-picker]`).scrollIntoView()
cy.get(`${context} [data-admin-theming-setting-color-picker]`).click({ force: true })

// Click on random color
cy.get('.color-picker__simple-color-circle').eq(randColour).click()

+ 38
- 29
cypress/e2e/theming/user-background.cy.ts View File

@@ -80,7 +80,7 @@ describe('User select shipped backgrounds and remove background', function() {

// Validate changed background and primary
cy.wait('@setBackground')
cy.waitUntil(() => validateBodyThemingCss('#a53c17', background))
cy.waitUntil(() => validateBodyThemingCss('#a53c17', background, '#652e11'))
})

it('Select a bright shipped background', function() {
@@ -95,21 +95,21 @@ describe('User select shipped backgrounds and remove background', function() {

// Validate changed background and primary
cy.wait('@setBackground')
cy.waitUntil(() => validateBodyThemingCss('#869171', background))
cy.waitUntil(() => validateBodyThemingCss('#56633d', background, '#dee0d3'))
})

it('Remove background', function() {
cy.intercept('*/apps/theming/background/custom').as('clearBackground')
cy.intercept('*/apps/theming/background/color').as('clearBackground')

// Clear background
cy.get('[data-user-theming-background-clear]').click()
cy.get('[data-user-theming-background-color]').click()

// Set the accessibility state
cy.get('[data-user-theming-background-clear]').should('have.attr', 'aria-pressed', 'true')
cy.get('[data-user-theming-background-color]').should('have.attr', 'aria-pressed', 'true')

// Validate clear background
cy.wait('@clearBackground')
cy.waitUntil(() => validateBodyThemingCss('#869171', null))
cy.waitUntil(() => validateBodyThemingCss('#56633d', null, '#dee0d3'))
})
})

@@ -129,14 +129,12 @@ describe('User select a custom color', function() {
it('Select a custom color', function() {
cy.intercept('*/apps/theming/background/color').as('setColor')

pickRandomColor()
cy.get('[data-user-theming-background-color]').click()
cy.get('.color-picker__simple-color-circle').eq(5).click()

// Validate custom colour change
cy.wait('@setColor')
cy.waitUntil(() => cy.window().then((win) => {
const primary = getComputedStyle(win.document.body).getPropertyValue('--color-primary')
return primary !== defaultPrimary && primary !== defaultPrimary
}))
cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, null, '#a5b872'))
})
})

@@ -154,10 +152,11 @@ describe('User select a bright custom color and remove background', function() {
})

it('Remove background', function() {
cy.intercept('*/apps/theming/background/custom').as('clearBackground')
cy.intercept('*/apps/theming/background/color').as('clearBackground')

// Clear background
cy.get('[data-user-theming-background-clear]').click()
cy.get('[data-user-theming-background-color]').click()
cy.get('[data-user-theming-background-color]').click()

// Validate clear background
cy.wait('@clearBackground')
@@ -168,7 +167,8 @@ describe('User select a bright custom color and remove background', function() {
cy.intercept('*/apps/theming/background/color').as('setColor')

// Pick one of the bright color preset
cy.contains('button', 'Change color').click()
cy.get('[data-user-theming-background-color]').scrollIntoView()
cy.get('[data-user-theming-background-color]').click()
cy.get('.color-picker__simple-color-circle:eq(4)').click()

// Validate custom colour change
@@ -194,7 +194,7 @@ describe('User select a bright custom color and remove background', function() {

// Validate changed background and primary
cy.wait('@setBackground')
cy.waitUntil(() => validateBodyThemingCss('#a53c17', background))
cy.waitUntil(() => validateBodyThemingCss('#a53c17', background, '#652e11'))
})

it('See the header NOT being inverted this time', function() {
@@ -240,15 +240,13 @@ describe('User select a custom background', function() {

// Wait for background to be set
cy.wait('@setBackground')
cy.waitUntil(() => validateBodyThemingCss('#4c0c04', 'apps/theming/background?v='))
cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, 'apps/theming/background?v=', '#2f2221'))
})
})

describe('User changes settings and reload the page', function() {
const image = 'image.jpg'
const primaryFromImage = '#4c0c04'

let selectedColor = ''
const colorFromImage = '#2f2221'

before(function() {
cy.createRandomUser().then((user: User) => {
@@ -280,28 +278,39 @@ describe('User changes settings and reload the page', function() {

// Wait for background to be set
cy.wait('@setBackground')
cy.waitUntil(() => validateBodyThemingCss(primaryFromImage, 'apps/theming/background?v='))
cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, 'apps/theming/background?v=', colorFromImage))
})

it('Select a custom color', function() {
cy.intercept('*/apps/theming/background/color').as('setColor')

cy.contains('button', 'Change color').click()
cy.get('[data-user-theming-background-color]').click()
cy.get('.color-picker__simple-color-circle:eq(5)').click()
cy.get('[data-user-theming-background-color]').click()

// Validate clear background
cy.wait('@setColor')
cy.waitUntil(() => cy.window().then((win) => {
selectedColor = getComputedStyle(win.document.body).getPropertyValue('--color-primary')
return selectedColor !== primaryFromImage
}))
cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, null, '#a5b872'))
})

it('Select a custom primary color', function() {
cy.intercept('/ocs/v2.php/apps/provisioning_api/api/v1/config/users/theming/primary_color').as('setPrimaryColor')

cy.get('[data-user-theming-primary-color-trigger]').scrollIntoView()
cy.get('[data-user-theming-primary-color-trigger]').click()
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(500)
cy.get('.color-picker__simple-color-circle').should('be.visible')
cy.get('.color-picker__simple-color-circle:eq(2)').click()
cy.get('[data-user-theming-primary-color-trigger]').click()

// Validate clear background
cy.wait('@setPrimaryColor')
cy.waitUntil(() => validateBodyThemingCss('#c98879', null, '#a5b872'))
})

it('Reload the page and validate persistent changes', function() {
cy.reload()
cy.waitUntil(() => validateBodyThemingCss(selectedColor, 'apps/theming/background?v='))

// validate accessibility state
cy.get('[data-user-theming-background-custom]').should('have.attr', 'aria-pressed', 'true')
cy.waitUntil(() => validateBodyThemingCss('#c98879', null, '#a5b872'))
})
})

+ 3
- 3
dist/core-legacy-unified-search.js
File diff suppressed because it is too large
View File


+ 1
- 1
dist/core-legacy-unified-search.js.map
File diff suppressed because it is too large
View File


+ 3
- 3
dist/core-main.js
File diff suppressed because it is too large
View File


+ 1
- 1
dist/core-main.js.map
File diff suppressed because it is too large
View File


+ 3
- 3
dist/core-unified-search.js
File diff suppressed because it is too large
View File


+ 1
- 1
dist/core-unified-search.js.map
File diff suppressed because it is too large
View File


+ 3
- 3
dist/dashboard-main.js
File diff suppressed because it is too large
View File


+ 1
- 1
dist/dashboard-main.js.map
File diff suppressed because it is too large
View File


+ 3
- 3
dist/settings-vue-settings-admin-ai.js
File diff suppressed because it is too large
View File


+ 1
- 1
dist/settings-vue-settings-admin-ai.js.map
File diff suppressed because it is too large
View File


+ 3
- 3
dist/theming-admin-theming.js
File diff suppressed because it is too large
View File


+ 1
- 1
dist/theming-admin-theming.js.map
File diff suppressed because it is too large
View File


+ 3
- 3
dist/theming-personal-theming.js
File diff suppressed because it is too large
View File


+ 22
- 0
dist/theming-personal-theming.js.license View File

@@ -1,5 +1,27 @@
/*! https://mths.be/punycode v1.4.1 by @mathias */

/**
* @copyright 2022 Christopher Ng <chrng8@gmail.com>
*
* @author Christopher Ng <chrng8@gmail.com>
*
* @license AGPL-3.0-or-later
*
* 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/>.
*
*/

/**
* @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
*

+ 1
- 1
dist/theming-personal-theming.js.map
File diff suppressed because it is too large
View File


+ 13
- 4
lib/private/Server.php View File

@@ -171,6 +171,7 @@ use OCA\Files_External\Service\GlobalStoragesService;
use OCA\Files_External\Service\UserGlobalStoragesService;
use OCA\Files_External\Service\UserStoragesService;
use OCA\Theming\ImageManager;
use OCA\Theming\Service\BackgroundService;
use OCA\Theming\ThemingDefaults;
use OCA\Theming\Util;
use OCP\Accounts\IAccountManager;
@@ -1184,13 +1185,20 @@ class Server extends ServerContainer implements IServerContainer {
}

if ($classExists && $c->get(\OCP\IConfig::class)->getSystemValueBool('installed', false) && $c->get(IAppManager::class)->isInstalled('theming') && $c->get(TrustedDomainHelper::class)->isTrustedDomain($c->getRequest()->getInsecureServerHost())) {
$backgroundService = new BackgroundService(
$c->get(IRootFolder::class),
$c->getAppDataDir('theming'),
$c->get(\OCP\IConfig::class),
$c->get(ISession::class)->get('user_id'),
);
$imageManager = new ImageManager(
$c->get(\OCP\IConfig::class),
$c->getAppDataDir('theming'),
$c->get(IURLGenerator::class),
$this->get(ICacheFactory::class),
$this->get(LoggerInterface::class),
$this->get(ITempManager::class)
$c->get(ICacheFactory::class),
$c->get(LoggerInterface::class),
$c->get(ITempManager::class),
$backgroundService,
);
return new ThemingDefaults(
$c->get(\OCP\IConfig::class),
@@ -1201,7 +1209,8 @@ class Server extends ServerContainer implements IServerContainer {
new Util($c->get(\OCP\IConfig::class), $this->get(IAppManager::class), $c->getAppDataDir('theming'), $imageManager),
$imageManager,
$c->get(IAppManager::class),
$c->get(INavigationManager::class)
$c->get(INavigationManager::class),
$backgroundService,
);
}
return new \OC_Defaults();

+ 14
- 1
lib/private/legacy/OC_Defaults.php View File

@@ -52,6 +52,7 @@ class OC_Defaults {
private $defaultDocBaseUrl;
private $defaultDocVersion;
private $defaultSlogan;
private $defaultColorBackground;
private $defaultColorPrimary;
private $defaultTextColorPrimary;
private $defaultProductName;
@@ -70,7 +71,8 @@ class OC_Defaults {
$this->defaultFDroidClientUrl = $config->getSystemValue('customclient_fdroid', 'https://f-droid.org/packages/com.nextcloud.client/');
$this->defaultDocBaseUrl = 'https://docs.nextcloud.com';
$this->defaultDocVersion = \OC_Util::getVersion()[0]; // used to generate doc links
$this->defaultColorPrimary = '#0082c9';
$this->defaultColorBackground = '#00679e';
$this->defaultColorPrimary = '#00679e';
$this->defaultTextColorPrimary = '#ffffff';
$this->defaultProductName = 'Nextcloud';

@@ -299,6 +301,17 @@ class OC_Defaults {
return $this->defaultColorPrimary;
}

/**
* Returns primary color
* @return string
*/
public function getColorBackground() {
if ($this->themeExist('getColorBackground')) {
return $this->theme->getColorBackground();
}
return $this->defaultColorBackground;
}

/**
* @return array scss variables to overwrite
*/

+ 8
- 0
themes/example/defaults.php View File

@@ -110,6 +110,14 @@ class OC_Theme {
return '#745bca';
}

/**
* Returns background color to be used
* @return string
*/
public function getColorBackground(): string {
return '#3d85c6';
}

/**
* Returns variables to overload defaults from core/css/variables.scss
* @return array

Loading…
Cancel
Save