Browse Source

Allow to remove the background and select a custom colour

Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
tags/v26.0.0beta1
John Molakvoæ 1 year ago
parent
commit
cedae7c6d7
No account linked to committer's email address

+ 5
- 0
apps/theming/appinfo/routes.php View File

@@ -88,6 +88,11 @@ return [
'url' => '/background/{type}',
'verb' => 'POST',
],
[
'name' => 'userTheme#deleteBackground',
'url' => '/background/custom',
'verb' => 'DELETE',
],
],
'ocs' => [
[

+ 148
- 0
apps/theming/css/settings-admin.css View File

@@ -0,0 +1,148 @@
#theming input {
width: 230px;
}
#theming input:focus,
#theming input:active {
padding-right: 30px;
}
#theming .fileupload {
display: none;
}
#theming div > label {
position: relative;
}
#theming .theme-undo {
position: absolute;
top: -7px;
right: 4px;
cursor: pointer;
opacity: 0.3;
padding: 7px;
vertical-align: top;
display: inline-block;
visibility: hidden;
height: 32px;
width: 32px;
}
#theming form.uploadButton {
width: 411px;
display: flex;
align-items: center;
}
#theming form .theme-undo,
#theming .theme-remove-bg {
cursor: pointer;
opacity: 0.3;
padding: 7px;
vertical-align: top;
display: inline-block;
float: right;
position: relative;
top: 4px;
right: 0px;
visibility: visible;
height: 32px;
width: 32px;
margin-left: auto;
}
#theming form .theme-undo:not([style*="display:"]) ~ .theme-remove-bg {
margin-left: 0;
}
#theming input[type=text]:hover + .theme-undo,
#theming input[type=text] + .theme-undo:hover,
#theming input[type=text]:focus + .theme-undo,
#theming input[type=text]:active + .theme-undo,
#theming input[type=url]:hover + .theme-undo,
#theming input[type=url] + .theme-undo:hover,
#theming input[type=url]:focus + .theme-undo,
#theming input[type=url]:active + .theme-undo {
visibility: visible;
}
#theming label span {
display: inline-block;
min-width: 175px;
max-width: 175px;
white-space: wrap;
padding: 8px 0px;
vertical-align: top;
}
#theming .icon-upload,
#theming .uploadButton .icon-loading-small {
padding: 8px 20px;
width: 20px;
margin: 2px 0px;
min-height: 32px;
display: inline-block;
}
#theming #theming_settings_status {
height: 26px;
margin: 10px;
}
#theming #theming_settings_loading {
display: inline-block;
vertical-align: middle;
margin-right: 10px;
}
#theming #theming_settings_msg {
vertical-align: middle;
border-radius: 3px;
}
#theming #theming-preview {
width: 230px;
height: 140px;
background-size: cover;
background-position: center center;
text-align: center;
margin-left: 178px;
margin-top: 10px;
margin-bottom: 20px;
cursor: pointer;
background-color: var(--color-primary-default);
background-image: var(--image-background-default, var(--image-background-plain, linear-gradient(40deg, #0082c9 0%, #30b6ff 100%)));
}
#theming #theming-preview #theming-preview-logo {
cursor: pointer;
width: 20%;
height: 20%;
margin-top: 20px;
display: inline-block;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
background-image: var(--image-logo, url("../../../core/img/logo/logo.svg"));
}
#theming .theming-hints {
margin-top: 20px;
}
#theming .image-preview {
display: inline-block;
width: 80px;
height: 36px;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
}
#theming #theming-preview-logoheader {
background-image: var(--image-logoheader);
}
#theming #theming-preview-favicon {
background-image: var(--image-favicon);
}
#theming #user-theming {
margin-top: 44px;
display: flex;
}
#theming #user-theming > div {
max-width: 400px;
margin-bottom: 44px;
}

/* transition effects for theming value changes */
#header {
transition: background-color 500ms linear;
}
#header svg, #header img {
transition: 500ms filter linear;
}

/*# sourceMappingURL=settings-admin.css.map */

+ 168
- 0
apps/theming/css/settings-admin.scss View File

@@ -0,0 +1,168 @@
#theming {
input {
width: 230px;
}

input:focus,
input:active {
padding-right: 30px;
}

.fileupload {
display: none;
}

div > label {
position: relative;
}

.theme-undo {
position: absolute;
top: -7px; // input padding
right: 4px; // input right margin + border
cursor: pointer;
opacity: .3;
padding: 7px;
vertical-align: top;
display: inline-block;
visibility: hidden;
height: 32px; // height of input
width: 32px; // height of input
}
form.uploadButton {
width: 411px;
display: flex;
align-items: center;
}
form .theme-undo,
.theme-remove-bg {
cursor: pointer;
opacity: .3;
padding: 7px;
vertical-align: top;
display: inline-block;
float: right;
position: relative;
top: 4px;
right: 0px;
visibility: visible;
height: 32px;
width: 32px;
// right align
margin-left: auto;
}
form .theme-undo:not([style*="display:"]) ~ .theme-remove-bg {
// Only align the undo button if both are shown
margin-left: 0;
}

input[type='text']:hover + .theme-undo,
input[type='text'] + .theme-undo:hover,
input[type='text']:focus + .theme-undo,
input[type='text']:active + .theme-undo,
input[type='url']:hover + .theme-undo,
input[type='url'] + .theme-undo:hover,
input[type='url']:focus + .theme-undo,
input[type='url']:active + .theme-undo{
visibility: visible;
}

label span {
display: inline-block;
min-width: 175px;
max-width: 175px;
white-space: wrap;
padding: 8px 0px;
vertical-align: top;
}

.icon-upload,
.uploadButton .icon-loading-small {
padding: 8px 20px;
width: 20px;
margin: 2px 0px;
min-height: 32px;
display: inline-block;
}

#theming_settings_status {
height: 26px;
margin: 10px;
}

#theming_settings_loading {
display: inline-block;
vertical-align: middle;
margin-right: 10px;
}

#theming_settings_msg {
vertical-align: middle;
border-radius: 3px;
}

#theming-preview {
width: 230px;
height: 140px;
background-size: cover;
background-position: center center;
text-align: center;
margin-left: 178px;
margin-top: 10px;
margin-bottom: 20px;
cursor: pointer;
background-color: var(--color-primary-default);
background-image: var(--image-background-default, var(--image-background-plain, linear-gradient(40deg, #0082c9 0%, #30b6ff 100%)));

#theming-preview-logo {
cursor: pointer;
width: 20%;
height: 20%;
margin-top: 20px;
display: inline-block;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
background-image: var(--image-logo, url('../../../core/img/logo/logo.svg'));
}
}

.theming-hints {
margin-top: 20px;
}

.image-preview {
display: inline-block;
width: 80px;
height: 36px;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
}

#theming-preview-logoheader {
// Only using --image-logoheader to show the custom value only
background-image: var(--image-logoheader);
}

#theming-preview-favicon {
background-image: var(--image-favicon);
}

#user-theming {
margin-top: 44px;
display: flex;
& > div {
max-width: 400px;
margin-bottom: 44px;
}
}
}

/* transition effects for theming value changes */
#header {
transition: background-color 500ms linear;
svg, img {
transition: 500ms filter linear;
}
}

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

@@ -36,10 +36,6 @@ class UpdateConfig extends Command {
'name', 'url', 'imprintUrl', 'privacyUrl', 'slogan', 'color', 'disable-user-theming'
];

public const SUPPORTED_IMAGE_KEYS = [
'background', 'logo', 'favicon', 'logoheader'
];

private $themingDefaults;
private $imageManager;
private $config;
@@ -87,14 +83,14 @@ class UpdateConfig extends Command {
$value = $this->config->getAppValue('theming', $key, '');
$output->writeln('- ' . $key . ': ' . $value . '');
}
foreach (self::SUPPORTED_IMAGE_KEYS as $key) {
foreach (ImageManager::SUPPORTED_IMAGE_KEYS as $key) {
$value = $this->config->getAppValue('theming', $key . 'Mime', '');
$output->writeln('- ' . $key . ': ' . $value . '');
}
return 0;
}

if (!in_array($key, self::SUPPORTED_KEYS, true) && !in_array($key, self::SUPPORTED_IMAGE_KEYS, true)) {
if (!in_array($key, self::SUPPORTED_KEYS, true) && !in_array($key, ImageManager::SUPPORTED_IMAGE_KEYS, true)) {
$output->writeln('<error>Invalid config key provided</error>');
return 1;
}
@@ -115,7 +111,7 @@ class UpdateConfig extends Command {
return 0;
}

if (in_array($key, self::SUPPORTED_IMAGE_KEYS, true)) {
if (in_array($key, ImageManager::SUPPORTED_IMAGE_KEYS, true)) {
if (strpos($value, '/') !== 0) {
$output->writeln('<error>The image file needs to be provided as an absolute path: ' . $value . '.</error>');
return 1;

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

@@ -155,21 +155,34 @@ class UserThemeController extends OCSController {
/**
* @NoAdminRequired
*/
public function setBackground(string $type = 'default', string $value = ''): JSONResponse {
public function deleteBackground(): JSONResponse {
$currentVersion = (int)$this->config->getUserValue($this->userId, Application::APP_ID, 'userCacheBuster', '0');
$this->backgroundService->deleteBackgroundImage();
return new JSONResponse([
'backgroundImage' => null,
'backgroundColor' => $this->themingDefaults->getColorPrimary(),
'version' => $currentVersion,
]);
}

/**
* @NoAdminRequired
*/
public function setBackground(string $type = BackgroundService::BACKGROUND_DEFAULT, string $value = ''): JSONResponse {
$currentVersion = (int)$this->config->getUserValue($this->userId, Application::APP_ID, 'userCacheBuster', '0');

try {
switch ($type) {
case 'shipped':
case BackgroundService::BACKGROUND_SHIPPED:
$this->backgroundService->setShippedBackground($value);
break;
case 'custom':
case BackgroundService::BACKGROUND_CUSTOM:
$this->backgroundService->setFileBackground($value);
break;
case 'color':
$this->backgroundService->setColorBackground($value);
break;
case 'default':
case BackgroundService::BACKGROUND_DEFAULT:
$this->backgroundService->setDefaultBackground();
break;
default:
@@ -185,8 +198,8 @@ class UserThemeController extends OCSController {
$this->config->setUserValue($this->userId, Application::APP_ID, 'userCacheBuster', (string)$currentVersion);

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

+ 31
- 6
apps/theming/lib/ImageManager.php View File

@@ -33,6 +33,8 @@
*/
namespace OCA\Theming;

use OCA\Theming\AppInfo\Application;
use OCA\Theming\Service\BackgroundService;
use OCP\Files\IAppData;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
@@ -45,7 +47,7 @@ use OCP\ITempManager;
use OCP\IURLGenerator;

class ImageManager {
public const SupportedImageKeys = ['background', 'logo', 'logoheader', 'favicon'];
public const SUPPORTED_IMAGE_KEYS = ['background', 'logo', 'logoheader', 'favicon'];

/** @var IConfig */
private $config;
@@ -74,8 +76,14 @@ class ImageManager {
$this->appData = $appData;
}

public function getImageUrl(string $key, bool $useSvg = true): string {
$cacheBusterCounter = $this->config->getAppValue('theming', 'cachebuster', '0');
/**
* Get a globally defined image (admin theming settings)
*
* @param string $key the image key
* @return string the image url
*/
public function getImageUrl(string $key): string {
$cacheBusterCounter = $this->config->getAppValue(Application::APP_ID, 'cachebuster', '0');
if ($this->hasImage($key)) {
return $this->urlGenerator->linkToRoute('theming.Theming.getImage', [ 'key' => $key ]) . '?v=' . $cacheBusterCounter;
}
@@ -86,13 +94,16 @@ class ImageManager {
case 'favicon':
return $this->urlGenerator->imagePath('core', 'logo/logo.png') . '?v=' . $cacheBusterCounter;
case 'background':
return $this->urlGenerator->imagePath('core', 'background.png') . '?v=' . $cacheBusterCounter;
return $this->urlGenerator->linkTo(Application::APP_ID, "img/background/" . BackgroundService::DEFAULT_BACKGROUND);
}
return '';
}

public function getImageUrlAbsolute(string $key, bool $useSvg = true): string {
return $this->urlGenerator->getAbsoluteURL($this->getImageUrl($key, $useSvg));
/**
* Get the absolute url. See getImageUrl
*/
public function getImageUrlAbsolute(string $key): string {
return $this->urlGenerator->getAbsoluteURL($this->getImageUrl($key));
}

/**
@@ -136,6 +147,20 @@ class ImageManager {
return $mimeSetting !== '';
}

/**
* @return array<string, array{mime: string, url: string}>
*/
public function getCustomImages(): array {
$images = [];
foreach (self::SUPPORTED_IMAGE_KEYS as $key) {
$images[$key] = [
'mime' => $this->config->getAppValue('theming', $key . 'Mime', ''),
'url' => $this->getImageUrl($key),
];
}
return $images;
}

/**
* Get folder for current theming files
*

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

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

use OCA\Theming\AppInfo\Application;
use OCP\App\IAppManager;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\IJobList;
use OCP\BackgroundJob\QueuedJob;

+ 18
- 2
apps/theming/lib/Listener/BeforeTemplateRenderedListener.php View File

@@ -84,16 +84,32 @@ class BeforeTemplateRenderedListener implements IEventListener {
if (!empty($user)) {
$userId = $user->getUID();

/** User background */
$this->initialState->provideInitialState(
'background',
$this->config->getUserValue($userId, Application::APP_ID, 'background', 'default'),
'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_image', BackgroundService::BACKGROUND_DEFAULT),
);

/**
* 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,
);

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

+ 18
- 11
apps/theming/lib/Service/BackgroundService.php View File

@@ -48,6 +48,12 @@ class BackgroundService {
public const DEFAULT_COLOR = '#0082c9';
public const DEFAULT_ACCESSIBLE_COLOR = '#006aa3';

public const BACKGROUND_SHIPPED = 'shipped';
public const BACKGROUND_CUSTOM = 'custom';
public const BACKGROUND_DEFAULT = 'default';
public const BACKGROUND_DISABLED = 'disabled';

public const DEFAULT_BACKGROUND = 'kamil-porembinski-clouds.jpg';
public const SHIPPED_BACKGROUNDS = [
'anatoly-mikhaltsov-butterfly-wing-scale.jpg' => [
'attribution' => 'Butterfly wing scale (Anatoly Mikhaltsov, CC BY-SA)',
@@ -153,7 +159,7 @@ class BackgroundService {
}

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

/**
@@ -165,7 +171,7 @@ class BackgroundService {
* @throws NoUserException
*/
public function setFileBackground($path): void {
$this->config->setUserValue($this->userId, Application::APP_ID, 'background', 'custom');
$this->config->setUserValue($this->userId, Application::APP_ID, 'background_image', self::BACKGROUND_DEFAULT);
$userFolder = $this->rootFolder->getUserFolder($this->userId);

/** @var File $file */
@@ -183,27 +189,28 @@ class BackgroundService {
if (!array_key_exists($fileName, self::SHIPPED_BACKGROUNDS)) {
throw new InvalidArgumentException('The given file name is invalid');
}
$this->config->setUserValue($this->userId, Application::APP_ID, 'background', $fileName);
$this->config->setUserValue($this->userId, Application::APP_ID, 'background_image', $fileName);
$this->setColorBackground(self::SHIPPED_BACKGROUNDS[$fileName]['primary_color']);
}

public function setColorBackground(string $color): void {
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);
$this->config->setUserValue($this->userId, Application::APP_ID, 'background_color', $color);
}

public function deleteBackgroundImage(): void {
$this->config->setUserValue($this->userId, Application::APP_ID, 'background_image', self::BACKGROUND_DISABLED);
}

public function getBackground(): ?ISimpleFile {
$background = $this->config->getUserValue($this->userId, Application::APP_ID, 'background', 'default');
if ($background === 'custom') {
$background = $this->config->getUserValue($this->userId, Application::APP_ID, 'background_image', self::BACKGROUND_DEFAULT);
if ($background === self::BACKGROUND_CUSTOM) {
try {
return $this->getAppDataFolder()->getFile('background.jpg');
} catch (NotFoundException | NotPermittedException $e) {
try {
// Fallback can be removed in 26
$dashboardFolder = $this->appDataFactory->get('dashboard');
return $dashboardFolder->getFolder($this->userId)->getFile('background.jpg');
} catch (\Throwable $t) {}
return null;
}
}
return null;

+ 19
- 14
apps/theming/lib/Themes/CommonThemeTrait.php View File

@@ -88,6 +88,10 @@ trait CommonThemeTrait {

$variables = [];

// Default last fallback values
$variables['--image-background-default'] = $backgroundDeleted ?: "url('" . $this->themingDefaults->getBackground() . "')";
$variables['--color-background-plain'] = $this->defaultPrimaryColor;

// If primary as background has been request or if we have a custom primary colour
// let's not define the background image
if ($backgroundDeleted) {
@@ -98,7 +102,7 @@ trait CommonThemeTrait {
}

// Register image variables only if custom-defined
foreach (ImageManager::SupportedImageKeys as $image) {
foreach (ImageManager::SUPPORTED_IMAGE_KEYS as $image) {
if ($this->imageManager->hasImage($image)) {
$imageUrl = $this->imageManager->getImageUrl($image);
if ($image === 'background') {
@@ -110,6 +114,7 @@ trait CommonThemeTrait {
$variables['--image-background-size'] = 'cover';
$variables['--image-background-default'] = "url('" . $imageUrl . "')";
}
// --image-background is overriden by user theming
$variables["--image-$image"] = "url('" . $imageUrl . "')";
}
}
@@ -129,32 +134,32 @@ trait CommonThemeTrait {
if ($user !== null
&& !$this->themingDefaults->isUserThemingDisabled()
&& $this->appManager->isEnabledForUser(Application::APP_ID)) {
$themingBackground = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'background', 'default');
$backgroundImage = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'background_image', BackgroundService::BACKGROUND_DEFAULT);
$currentVersion = (int)$this->config->getUserValue($user->getUID(), Application::APP_ID, 'userCacheBuster', '0');

// The user uploaded a custom background
if ($themingBackground === 'custom') {
$cacheBuster = substr(sha1($user->getUID() . '_' . $currentVersion), 0, 8);
// The user removed the background
if ($backgroundImage === BackgroundService::BACKGROUND_DISABLED) {
return [
'--image-background' => "url('" . $this->urlGenerator->linkToRouteAbsolute('theming.userTheme.getBackground') . "?v=$cacheBuster')",
// TODO: implement primary color from custom background --color-background-plain
'--image-background' => 'no',
'--color-background-plain' => $this->themingDefaults->getColorPrimary(),
];
}

// The user picked a shipped background
if (isset(BackgroundService::SHIPPED_BACKGROUNDS[$themingBackground])) {
// 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->linkTo(Application::APP_ID, "/img/background/$themingBackground") . "')",
'--image-background' => "url('" . $this->urlGenerator->linkToRouteAbsolute('theming.userTheme.getBackground') . "?v=$cacheBuster')",
'--color-background-plain' => $this->themingDefaults->getColorPrimary(),
'--background-image-invert-if-bright' => BackgroundService::SHIPPED_BACKGROUNDS[$themingBackground]['theming'] ?? null === BackgroundService::THEMING_MODE_DARK ? 'invert(100%)' : 'no',
];
}

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

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

@@ -190,11 +190,6 @@ class DefaultTheme implements ITheme {
'--background-invert-if-dark' => 'no',
'--background-invert-if-bright' => 'invert(100%)',
'--background-image-invert-if-bright' => 'no',

// Default last fallback values
'--image-background' => "url('" . $this->urlGenerator->imagePath('core', 'app-background.jpg') . "')",
'--image-background-default' => "url('" . $this->urlGenerator->imagePath('core', 'app-background.jpg') . "')",
'--color-background-plain' => $this->defaultPrimaryColor,
];

// Primary variables

+ 2
- 13
apps/theming/lib/ThemingDefaults.php View File

@@ -226,23 +226,12 @@ class ThemingDefaults extends \OC_Defaults {
}

// user-defined primary color
$themingBackground = '';
if (!empty($user)) {
$themingBackground = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'background', '');
// If the user selected the default background
if ($themingBackground === '') {
return BackgroundService::DEFAULT_COLOR;
}

$themingBackground = $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', $themingBackground)) {
return $themingBackground;
}

// if the user-selected background is a background reference
if (isset(BackgroundService::SHIPPED_BACKGROUNDS[$themingBackground]['primary_color'])) {
return BackgroundService::SHIPPED_BACKGROUNDS[$themingBackground]['primary_color'];
}
}

// If the default color is not valid, return the default background one
@@ -477,7 +466,7 @@ class ThemingDefaults extends \OC_Defaults {
$returnValue = $this->getSlogan();
break;
case 'color':
$returnValue = $this->getColorPrimary();
$returnValue = $this->getDefaultColorPrimary();
break;
case 'logo':
case 'logoheader':

+ 16
- 9
apps/theming/src/UserThemes.vue View File

@@ -69,10 +69,7 @@
</template>
<template v-else>
<p>{{ t('theming', 'Set a custom background') }}</p>
<BackgroundSettings class="background__grid"
:background="background"
:theming-default-background="themingDefaultBackground"
@update:background="updateBackground" />
<BackgroundSettings class="background__grid" @update:background="refreshGlobalStyles" />
</template>
</NcSettingsSection>
</section>
@@ -92,8 +89,6 @@ const availableThemes = loadState('theming', 'themes', [])
const enforceTheme = loadState('theming', 'enforceTheme', '')
const shortcutsDisabled = loadState('theming', 'shortcutsDisabled', false)

const background = loadState('theming', 'background')
const themingDefaultBackground = loadState('theming', 'themingDefaultBackground')
const isUserThemingDisabled = loadState('theming', 'isUserThemingDisabled')

console.debug('Available themes', availableThemes)
@@ -111,10 +106,10 @@ export default {
data() {
return {
availableThemes,

// Admin defined configs
enforceTheme,
shortcutsDisabled,
background,
themingDefaultBackground,
isUserThemingDisabled,
}
},
@@ -173,9 +168,21 @@ 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.$emit('update:background')
this.refreshGlobalStyles()
},

changeTheme({ enabled, id }) {

+ 127
- 88
apps/theming/src/components/BackgroundSettings.vue View File

@@ -26,68 +26,75 @@
<template>
<div class="background-selector">
<!-- Custom background -->
<button class="background filepicker"
:class="{ active: background === 'custom' }"
<button class="background background__filepicker"
:class="{ 'background--active': backgroundImage === 'custom' }"
tabindex="0"
@click="pickFile">
{{ t('theming', 'Pick from Files') }}
{{ t('theming', 'Custom background') }}
</button>

<!-- Default background -->
<button class="background default"
<button class="background background__default"
:class="{ 'icon-loading': loading === 'default', 'background--active': backgroundImage === 'default' }"
:data-color-bright="invertTextColor(Theming.defaultColor)"
:style="{ '--border-color': Theming.defaultColor }"
tabindex="0"
:class="{ 'icon-loading': loading === 'default', active: background === 'default' }"
@click="setDefault">
{{ t('theming', 'Default image') }}
{{ t('theming', 'Default background') }}
<Check :size="44" />
</button>

<!-- Custom color picker -->
<NcColorPicker v-model="Theming.color" @input="debouncePickColor">
<button class="background color"
:class="{ active: background === Theming.color}"
tabindex="0"
<button class="background background__color"
:data-color="Theming.color"
:data-color-bright="invertTextColor(Theming.color)"
:style="{ backgroundColor: Theming.color, color: invertTextColor(Theming.color) ? '#000000' : '#ffffff'}">
{{ t('theming', 'Custom color') }}
:style="{ backgroundColor: Theming.color, '--border-color': Theming.color}"
tabindex="0">
{{ t('theming', 'Change color') }}
</button>
</NcColorPicker>

<!-- Default admin primary color -->
<button class="background color"
:class="{ active: background === Theming.defaultColor }"
tabindex="0"
:data-color="Theming.defaultColor"
:data-color-bright="invertTextColor(Theming.defaultColor)"
:style="{ color: invertTextColor(Theming.defaultColor) ? '#000000' : '#ffffff'}"
@click="debouncePickColor">
{{ t('theming', 'Plain background') }}
</button>

<!-- Background set selection -->
<button v-for="shippedBackground in shippedBackgrounds"
:key="shippedBackground.name"
v-tooltip="shippedBackground.details.attribution"
:class="{ 'icon-loading': loading === shippedBackground.name, active: background === shippedBackground.name }"
tabindex="0"
class="background"
:class="{ 'icon-loading': loading === shippedBackground.name, 'background--active': backgroundImage === shippedBackground.name }"
:data-color-bright="shippedBackground.details.theming === 'dark'"
:style="{ 'background-image': 'url(' + shippedBackground.preview + ')' }"
@click="setShipped(shippedBackground.name)" />
:style="{ backgroundImage: 'url(' + shippedBackground.preview + ')', '--border-color': shippedBackground.details.primary_color }"
class="background background__shipped"
tabindex="0"
@click="setShipped(shippedBackground.name)">
<Check :size="44" />
</button>

<!-- Remove background -->
<button class="background background__delete"
tabindex="0"
@click="removeBackground">
{{ t('theming', 'Remove background') }}
<Close :size="24" />
</button>
</div>
</template>

<script>
import { generateUrl } from '@nextcloud/router'
import { getBackgroundUrl } from '../helpers/getBackgroundUrl.js'
import { generateFilePath, generateUrl } from '@nextcloud/router'
import { loadState } from '@nextcloud/initial-state'
import { prefixWithBaseUrl } from '../helpers/prefixWithBaseUrl.js'
import axios from '@nextcloud/axios'
import Check from 'vue-material-design-icons/Check.vue'
import Close from 'vue-material-design-icons/Close.vue'
import debounce from 'debounce'
import NcColorPicker from '@nextcloud/vue/dist/Components/NcColorPicker'
import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip'

const backgroundColor = loadState('theming', 'backgroundColor')
const backgroundImage = loadState('theming', 'backgroundImage')
const shippedBackgroundList = loadState('theming', 'shippedBackgrounds')
const themingDefaultBackground = loadState('theming', 'themingDefaultBackground')
const defaultShippedBackground = loadState('theming', 'defaultShippedBackground')

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

export default {
name: 'BackgroundSettings',
@@ -96,38 +103,49 @@ export default {
},

components: {
Check,
Close,
NcColorPicker,
},

props: {
background: {
type: String,
default: 'default',
},
themingDefaultBackground: {
type: String,
default: '',
},
},

data() {
return {
backgroundImage: generateUrl('/apps/theming/background') + '?v=' + Date.now(),
loading: false,
Theming: loadState('theming', 'data', {}),

// User background image and color settings
backgroundImage,
backgroundColor,
}
},

computed: {
shippedBackgrounds() {
return Object.keys(shippedBackgroundList).map(fileName => {
return {
name: fileName,
url: prefixWithBaseUrl(fileName),
preview: prefixWithBaseUrl('preview/' + fileName),
details: shippedBackgroundList[fileName],
}
})
return Object.keys(shippedBackgroundList)
.map(fileName => {
return {
name: fileName,
url: prefixWithBaseUrl(fileName),
preview: prefixWithBaseUrl('preview/' + fileName),
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
},

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

@@ -163,20 +181,24 @@ export default {
: null
},

/**
* Update local state
*
* @param {object} data destructuring object
* @param {string} data.backgroundColor background color value
* @param {string} data.backgroundImage background image value
* @param {string} data.version cache buster number
* @see https://github.com/nextcloud/server/blob/c78bd45c64d9695724fc44fe8453a88824b85f2f/apps/theming/lib/Controller/UserThemeController.php#L187-L191
*/
async update(data) {
const background = data.type === 'custom' || data.type === 'default' ? data.type : data.value
this.backgroundImage = getBackgroundUrl(background, data.version, this.themingDefaultBackground)
if (data.type === 'color' || (data.type === 'default' && this.themingDefaultBackground === 'backgroundColor')) {
this.$emit('update:background', data)
this.loading = false
return
}
const image = new Image()
image.onload = () => {
this.$emit('update:background', data)
this.loading = false
}
image.src = this.backgroundImage
// Update state
this.backgroundImage = data.backgroundImage
this.backgroundColor = data.backgroundColor
this.Theming.color = data.backgroundColor

// Notify parent and reload style
this.$emit('update:background')
this.loading = false
},

async setDefault() {
@@ -197,15 +219,21 @@ export default {
this.update(result.data)
},

debouncePickColor: debounce(function() {
this.pickColor(...arguments)
}, 200),
async removeBackground() {
this.loading = 'remove'
const result = await axios.delete(generateUrl('/apps/theming/background/custom'))
this.update(result.data)
},

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

pickFile() {
window.OC.dialogs.filepicker(t('theming', 'Select a background from your files'), (path, type) => {
@@ -225,50 +253,61 @@ export default {
justify-content: center;

.background {
overflow: hidden;
width: 176px;
height: 96px;
margin: 8px;
background-size: cover;
background-position: center center;
text-align: center;
border-radius: var(--border-radius-large);
border: 2px solid var(--color-main-background);
overflow: hidden;
border-radius: var(--border-radius-large);
background-position: center center;
background-size: cover;

&.current {
background-image: var(--color-background-dark);
&__default {
background-color: var(--color-primary-default);
background-image: var(--image-background-default);
}

&.filepicker, &.default, &.color {
&__filepicker, &__default, &__color {
border-color: var(--color-border);
}

&.color {
background-color: var(--color-primary-default);
&__color {
color: var(--color-primary-text);
background-color: var(--color-primary-default);
}

// Text and svg icon dark on bright background
&[data-color-bright] {
color: black;
}

&.active,
&--active,
&:hover,
&:focus {
border: 2px solid var(--color-primary);
// Use theme color primary, see inline css variable in template
border: 2px solid var(--border-color, var(--color-primary)) !important;
}

&.active:not(.icon-loading) {
&:after {
background-image: var(--original-icon-checkmark-white);
background-repeat: no-repeat;
background-position: center;
background-size: 44px;
content: '';
display: block;
height: 100%;
// Icon
span {
margin: 4px;
}

&__default,
&__shipped {
color: white;
span {
display: none;
}
}

&[data-color-bright]:after {
background-image: var(--original-icon-checkmark-dark);
&--active:not(.icon-loading) {
span {
display: block;
}
}
}
}

</style>

+ 0
- 49
apps/theming/src/helpers/getBackgroundUrl.js View File

@@ -1,49 +0,0 @@
/**
* @copyright Copyright (c) 2020 Julius Härtl <jus@bitgrid.net>
*
* @author Avior <florian.bouillon@delta-wings.net>
* @author Julien Veyssier <eneiluj@posteo.net>
* @author Julius Härtl <jus@bitgrid.net>
*
* @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/>.
*
*/

import { generateUrl } from '@nextcloud/router'
import { prefixWithBaseUrl } from './prefixWithBaseUrl.js'

export const getBackgroundUrl = (background, time = 0, themingDefaultBackground = '') => {
const enabledThemes = window.OCA?.Theming?.enabledThemes || []
const isDarkTheme = (enabledThemes.length === 0 || enabledThemes[0] === 'default')
? window.matchMedia('(prefers-color-scheme: dark)').matches
: enabledThemes.join('').indexOf('dark') !== -1

if (background === 'default') {
if (themingDefaultBackground && themingDefaultBackground !== 'backgroundColor') {
return generateUrl('/apps/theming/image/background') + '?v=' + window.OCA.Theming.cacheBuster
}

if (isDarkTheme) {
return prefixWithBaseUrl('eduardo-neves-pedra-azul.jpg')
}

return prefixWithBaseUrl('kamil-porembinski-clouds.jpg')
} else if (background === 'custom') {
return generateUrl('/apps/theming/background') + '?v=' + time
}

return prefixWithBaseUrl(background)
}

+ 0
- 25
apps/theming/src/helpers/prefixWithBaseUrl.js View File

@@ -1,25 +0,0 @@
/**
* @copyright Copyright (c) 2020 Julius Härtl <jus@bitgrid.net>
*
* @author Julius Härtl <jus@bitgrid.net>
*
* @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/>.
*
*/

import { generateFilePath } from '@nextcloud/router'

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

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

@@ -680,7 +680,7 @@ class ThemingControllerTest extends TestCase {

public function testGetLoginBackground() {
$file = $this->createMock(ISimpleFile::class);
$file->method('getName')->willReturn('background.png');
$file->method('getName')->willReturn('app-background.jpg');
$file->method('getMTime')->willReturn(42);
$this->imageManager->expects($this->once())
->method('getImage')

+ 4
- 4
apps/theming/tests/ThemingDefaultsTest.php View File

@@ -484,7 +484,7 @@ class ThemingDefaultsTest extends TestCase {
$this->config
->expects($this->once())
->method('getUserValue')
->with('user', 'theming', 'background', '')
->with('user', 'theming', 'background_image', '')
->willReturn(array_keys(BackgroundService::SHIPPED_BACKGROUNDS)[$backgroundIndex]);
$this->config
->expects($this->exactly(2))
@@ -509,7 +509,7 @@ class ThemingDefaultsTest extends TestCase {
$this->config
->expects($this->once())
->method('getUserValue')
->with('user', 'theming', 'background', '')
->with('user', 'theming', 'background_image', '')
->willReturn('#fff');
$this->config
->expects($this->exactly(2))
@@ -534,7 +534,7 @@ class ThemingDefaultsTest extends TestCase {
$this->config
->expects($this->once())
->method('getUserValue')
->with('user', 'theming', 'background', '')
->with('user', 'theming', 'background_image', '')
->willReturn('nextcloud');
$this->config
->expects($this->exactly(3))
@@ -781,7 +781,7 @@ class ThemingDefaultsTest extends TestCase {
['logo', true, 'custom-logo?v=0'],
['logoheader', true, 'custom-logoheader?v=0'],
['favicon', true, 'custom-favicon?v=0'],
['background', true, 'custom-background?v=0'],
['background_image', true, 'custom-background?v=0'],
]);

$expected = [

+ 1
- 1
core/css/apps.css View File

@@ -97,7 +97,7 @@ html {

body {
background-color: var(--color-background-plain, var(--color-main-background));
background-image: var(--image-background-plain, var(--image-background));
background-image: var(--image-background-plain, var(--image-background, var(--image-background-default)));
background-size: cover;
background-position: center;
position: fixed;

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

@@ -47,7 +47,7 @@ html {

body {
background-color: var(--color-background-plain, var(--color-main-background));
background-image: var(--image-background-plain, var(--image-background));
background-image: var(--image-background-plain, var(--image-background, var(--image-background-default)));
background-size: cover;
background-position: center;
position: fixed;

+ 2
- 2
core/css/guest.css View File

@@ -23,8 +23,8 @@ body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
color: var(--color-text);
text-align: center;
background-color: var(--color-primary-default, var(--color-primary));
background-image: var(--image-background-plain, var(--image-background, url('../../../core/img/app-background.jpg'), linear-gradient(40deg, #0082c9 0%, #30b6ff 100%)));
background-color: var(--color-main-background-not-plain, var(--color-primary));
background-image: var(--image-background, var(--image-background-plain, linear-gradient(40deg, #0082c9 0%, #30b6ff 100%)));
background-attachment: fixed;
min-height: 100%; /* fix sticky footer */
height: auto;

+ 1
- 1
core/css/server.css View File

@@ -2679,7 +2679,7 @@ html {

body {
background-color: var(--color-background-plain, var(--color-main-background));
background-image: var(--image-background-plain, var(--image-background));
background-image: var(--image-background-plain, var(--image-background, var(--image-background-default)));
background-size: cover;
background-position: center;
position: fixed;

BIN
core/img/app-background.jpg View File


BIN
core/img/background.png View File


+ 0
- 1
core/img/background.svg
File diff suppressed because it is too large
View File


Loading…
Cancel
Save