Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>tags/v26.0.0beta1
@@ -88,6 +88,11 @@ return [ | |||
'url' => '/background/{type}', | |||
'verb' => 'POST', | |||
], | |||
[ | |||
'name' => 'userTheme#deleteBackground', | |||
'url' => '/background/custom', | |||
'verb' => 'DELETE', | |||
], | |||
], | |||
'ocs' => [ | |||
[ |
@@ -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 */ |
@@ -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; | |||
} | |||
} |
@@ -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; |
@@ -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, | |||
]); | |||
} |
@@ -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 | |||
* |
@@ -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; |
@@ -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, |
@@ -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; |
@@ -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', | |||
]; | |||
} | |||
} |
@@ -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 |
@@ -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': |
@@ -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 }) { |
@@ -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> |
@@ -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) | |||
} |
@@ -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 |
@@ -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') |
@@ -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 = [ |
@@ -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; |
@@ -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; |
@@ -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; |
@@ -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; |