Dynamic Themes
Horizon themes are now configurable at a user level, through the use of cookies. The themes that can be set are configurable at a deployment level through settings.py. Horizon can be configured to run with multiple themes, and allow users to choose which themes they wish to run. Django Compressor: In order to support dynamic themes, each theme configuration must be pre-compiled through the Django compressor. By making use of its built in COMPRESS_OFFLINE_CONTEXT, we now return a generator to create each of the theme's necessary offline contexts. Templates: Horizon themes allowed template overrides via their 'templates' subfolder. In order to maintain this parity, a custom theme template loader was created. It is run before the other loads, and simply looks for a Django template in the current theme (cookie driven) before diverting to the previous template loaders. Static Files: Horizon themes allowed static overrides of the images in 'dashboard/img' folder. A template tag, 'themable_asset' was created to maintain this parity. Any asset that is wished to be made themable, given that it is located in Horizon's 'static/dashboard' folder, can now be made ot be themable. By making this a template tag, this gives the developers more granular control over what branders can customize. Angular and Plugins: By far, the trickiest part of this task, Angular and Plugins are dynamic in the files that they 'discover'. SCSS is not flexible in this manner at ALL. SCSS disallows the importation of a variable name. To get around this, themes.scss was created as a Django template. This template is the top level import file for all styles within Horizon, and therefore, allows ALL the scss files to share a common namespace and thus, can use shared variables as well as extend shared styles. Other: This change is fundamental, in that it changes the method by which Horizon ingests its SCSS files. Many problems existing in the previous implementation, in an effort to make Horizon flexible, its SCSS was made very inflexible. This patch corrects those problems. Change-Id: Ic48b4b5c1d1a41f1e01a8d52784c9d38d192c8f1 Implements: blueprint horizon-dynamic-theme Closes-Bug: #1480427
This commit is contained in:
parent
c9fdecc8e8
commit
c9de52d6bb
@ -11,11 +11,42 @@ through the use of a theme. A theme is a directory containing a
|
||||
and a ``_styles.scss`` file with additional styles to load after dashboard
|
||||
styles have loaded.
|
||||
|
||||
To use a custom theme, set ``CUSTOM_THEME_PATH`` in ``local_settings.py`` to
|
||||
the directory location for the theme (e.g., ``"themes/material"``). The
|
||||
path can either be relative to the ``openstack_dashboard`` directory or an
|
||||
absolute path to an accessible location on the file system. The default
|
||||
``CUSTOM_THEME_PATH`` is ``themes/default``.
|
||||
As of the Mitaka release, Horizon can be configured to run with multiple
|
||||
themes available at run time. It uses a browser cookie to allow users to
|
||||
toggle between the configured themes. By default, Horizon is configured
|
||||
with the two standard themes available: 'default' and 'material'.
|
||||
|
||||
To configure or alter the available themes, set ``AVAILABLE_THEMES`` in
|
||||
``local_settings.py`` to a list of tuples, such that ``('name', 'label', 'path')``
|
||||
|
||||
``name``
|
||||
The key by which the theme value is stored within the cookie
|
||||
|
||||
``label``
|
||||
The label shown in the theme toggle under the User Menu
|
||||
|
||||
``path``
|
||||
The directory location for the theme. The path must be relative to the
|
||||
``openstack_dashboard`` directory or an absolute path to an accessible
|
||||
location on the file system
|
||||
|
||||
To use a custom theme, set ``AVAILABLE_THEMES`` in ``local_settings.py`` to
|
||||
a list of themes. If you wish to run in a mode similar to legacy Horizon,
|
||||
set ``AVAILABLE_THEMES`` with a single tuple, and the theme toggle will not
|
||||
be available at all through the application to allow user configuration themes.
|
||||
|
||||
For example, a configuration with multiple themes::
|
||||
|
||||
AVAILABLE_THEMES = [
|
||||
('default', 'Default', 'themes/default'),
|
||||
('material', 'Material', 'themes/material'),
|
||||
]
|
||||
|
||||
A configuration with a single theme::
|
||||
|
||||
AVAILABLE_THEMES = [
|
||||
('default', 'Default', 'themes/default'),
|
||||
]
|
||||
|
||||
Both the Dashboard custom variables and Bootstrap variables can be overridden.
|
||||
For a full list of the Dashboard SCSS variables that can be changed, see the
|
||||
@ -39,40 +70,31 @@ theme's ``_variables.scss``::
|
||||
Once you have made your changes you must re-generate the static files with
|
||||
``./run_tests.py -m collectstatic``.
|
||||
|
||||
The Default Theme
|
||||
~~~~~~~~~~~~~~~~~
|
||||
By default, all of the themes configured by ``AVAILABLE_THEMES`` setting are
|
||||
collected by horizon during the `collectstatic` process. By default, the themes
|
||||
are collected into the dynamic `static/themes` directory, but this location can
|
||||
be customized via the ``local_settings.py`` variable: ``THEME_COLLECTION_DIR``
|
||||
|
||||
By default, only the themes configured by the settings: `DEFAULT_THEME_PATH`
|
||||
and `CUSTOM_THEME_PATH` are collected during the `collectstatic` process into
|
||||
the dynamic `static` directory into the following directories::
|
||||
Once collected, any theme configured via ``AVAILABLE_THEMES`` is available to
|
||||
inherit from by importing its variables and styles from its collection
|
||||
directory. The following is an example of inheriting from the material theme::
|
||||
|
||||
CUSTOM_THEME_PATH: /custom
|
||||
DEFAULT_THEME_PATH: /themes/default
|
||||
@import "/themes/material/variables";
|
||||
@import "/themes/material/styles";
|
||||
|
||||
Bootswatch
|
||||
~~~~~~~~~~
|
||||
|
||||
.. NOTE::
|
||||
|
||||
However, if `DEFAULT_THEME_PATH` and `CUSTOM_THEME_PATH` are equal, then the
|
||||
only directory that will be collected into `static` is `/custom`.
|
||||
|
||||
By default, `DEFAULT_THEME_PATH` is set to the 'default' theme path, therefore
|
||||
if you wish to inherit from another theme (i.e. `material`) that will need to
|
||||
be collected from the Horizon code base, then you just update
|
||||
`DEFAULT_THEME_PATH` to ensure that the theme you wish to inherit from is
|
||||
available in the `static` directory.
|
||||
|
||||
If you need to inherit from a Bootswatch theme, no further changes to settings
|
||||
are necessary. This is due to the fact that Bootswatch is loaded as a 3rd
|
||||
party static asset, and therefore is automatically collected into the `static`
|
||||
directory in `/horizon/lib/`. Just add @imports to your theme's scss files::
|
||||
Horizon packages the Bootswatch SCSS files for use with its ``material`` theme.
|
||||
Because of this, it is simple to use an existing Bootswatch theme as a base.
|
||||
This is due to the fact that Bootswatch is loaded as a 3rd party static asset,
|
||||
and therefore is automatically collected into the `static` directory in
|
||||
`/horizon/lib/`. The following is an example of how to inherit from Bootswatch's
|
||||
``darkly`` theme::
|
||||
|
||||
@import "/horizon/lib/bootswatch/darkly/variables";
|
||||
@import "/horizon/lib/bootswatch/darkly/bootswatch";
|
||||
|
||||
.. NOTE::
|
||||
|
||||
The above only shows how to import the 'darkly' theme as an example, but any
|
||||
of the Bootswatch theme can be imported this way.
|
||||
|
||||
Organizing Your Theme Directory
|
||||
-------------------------------
|
||||
@ -108,17 +130,18 @@ directory structure that the extending template expects.
|
||||
For example, if you wish to customize the sidebar, Horizon expects the template
|
||||
to live at ``horizon/_sidebar.html``. You would need to duplicate that
|
||||
directory structure under your templates directory, such that your override
|
||||
would live at ``{CUSTOM_THEME_PATH}/templates/horizon/_sidebar.html``.
|
||||
would live at ``{ theme_path }/templates/horizon/_sidebar.html``.
|
||||
|
||||
The ``img`` Folder
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
|
||||
If the static root of the theme folder contains an ``img`` directory,
|
||||
then all images contained within ``dashboard/img`` can be overridden by
|
||||
providing a file with the same name.
|
||||
then all images that make use of the {% themable_asset %} templatetag
|
||||
can be overridden.
|
||||
|
||||
For a complete list of the images that can be overridden this way, see:
|
||||
``openstack_dashboard/static/dashboard/img``
|
||||
These assets include logo.png, splash-logo.png and favicon.ico, however
|
||||
overriding the SVG/GIF assets used by Heat within the `dashboard/img` folder
|
||||
is not currently supported.
|
||||
|
||||
Customizing the Logo
|
||||
--------------------
|
||||
|
@ -422,6 +422,85 @@ This example sorts flavors by vcpus in descending order::
|
||||
'reverse': True,
|
||||
}
|
||||
|
||||
.. _available_themes:
|
||||
|
||||
``AVAILABLE_THEMES``
|
||||
--------------------
|
||||
|
||||
.. versionadded:: 9.0.0(Mitaka)
|
||||
|
||||
Default: ``AVAILABLE_THEMES = [
|
||||
('default', 'Default', 'themes/default'),
|
||||
('material', 'Material', 'themes/material'),
|
||||
]``
|
||||
|
||||
This setting tells Horizon which themes to use.
|
||||
|
||||
A list of tuples which define multiple themes. The tuple format is
|
||||
``('{{ theme_name }}', '{{ theme_label }}', '{{ theme_path }}')``.
|
||||
|
||||
The ``theme_name`` is the name used to define the directory which
|
||||
the theme is collected into, under ``/{{ THEME_COLLECTION_DIR }}``.
|
||||
It also specifies the key by which the selected theme is stored in
|
||||
the browser's cookie.
|
||||
|
||||
The ``theme_label`` is the user-facing label that is shown in the
|
||||
theme picker. The theme picker is only visible if more than one
|
||||
theme is configured, and shows under the topnav's user menu.
|
||||
|
||||
By default, the ``theme path`` is the directory that will serve as
|
||||
the static root of the theme and the entire contents of the directory
|
||||
is served up at ``/{{ THEME_COLLECTION_DIR }}/{{ theme_name }}``.
|
||||
If you wish to include content other than static files in a theme
|
||||
directory, but do not wish that content to be served up, then you
|
||||
can create a sub directory named ``static``. If the theme folder
|
||||
contains a sub-directory with the name ``static``, then
|
||||
``static/custom/static``` will be used as the root for the content
|
||||
served at ``/static/custom``.
|
||||
|
||||
The static root of the theme folder must always contain a _variables.scss
|
||||
file and a _styles.scss file. These must contain or import all the
|
||||
bootstrap and horizon specific variables and styles which are used to style
|
||||
the GUI. For example themes, see: /horizon/openstack_dashboard/themes/
|
||||
|
||||
Horizon ships with two themes configured. 'default' is the default theme,
|
||||
and 'material' is based on Google's Material Design.
|
||||
|
||||
``DEFAULT_THEME``
|
||||
-----------------
|
||||
|
||||
.. versionadded:: 9.0.0(Mitaka)
|
||||
|
||||
Default: ``"default"``
|
||||
|
||||
This setting tells Horizon which theme to use if the user has not
|
||||
yet selected a theme through the theme picker and therefore set the
|
||||
cookie value. This value represents the ``theme_name`` key that is
|
||||
used from ``AVAILABLE_THEMES``. To use this setting, the theme must
|
||||
also be configured inside of ``AVAILABLE_THEMES``.
|
||||
|
||||
``THEME_COLLECTION_DIR``
|
||||
------------------------
|
||||
|
||||
.. versionadded:: 9.0.0(Mitaka)
|
||||
|
||||
Default: ``"themes"``
|
||||
|
||||
This setting tells Horizon which static directory to collect the
|
||||
available themes into, and therefore which URL points to the theme
|
||||
colleciton root. For example, the default theme would be accessible
|
||||
via ``/{{ STATIC_URL }}/themes/default``.
|
||||
|
||||
``THEME_COOKIE_NAME``
|
||||
---------------------
|
||||
|
||||
.. versionadded:: 9.0.0(Mitaka)
|
||||
|
||||
Default: ``"theme"``
|
||||
|
||||
This setting tells Horizon in which cookie key to store the currently
|
||||
set theme. The cookie expiration is currently set to a year.
|
||||
|
||||
.. _custom_theme_path:
|
||||
|
||||
``CUSTOM_THEME_PATH``
|
||||
@ -429,6 +508,8 @@ This example sorts flavors by vcpus in descending order::
|
||||
|
||||
.. versionadded:: 2015.1(Kilo)
|
||||
|
||||
(Deprecated)
|
||||
|
||||
Default: ``"themes/default"``
|
||||
|
||||
This setting tells Horizon to use a directory as a custom theme.
|
||||
@ -450,12 +531,17 @@ the GUI. For example themes, see: /horizon/openstack_dashboard/themes/
|
||||
Horizon ships with one alternate theme based on Google's Material Design. To
|
||||
use the alternate theme, set your CUSTOM_THEME_PATH to ``themes/material``.
|
||||
|
||||
This option is now marked as "deprecated" and will be removed in Newton or
|
||||
a later release. Themes are now controlled by AVAILABLE_THEMES. We suggest
|
||||
changing your custom theme settings to use this option instead.
|
||||
|
||||
``DEFAULT_THEME_PATH``
|
||||
----------------------
|
||||
|
||||
.. versionadded:: 8.0.0(Liberty)
|
||||
|
||||
(Deprecated)
|
||||
|
||||
Default: ``"themes/default"``
|
||||
|
||||
This setting allows Horizon to collect an additional theme during static
|
||||
@ -465,6 +551,8 @@ if CUSTOM_THEME_PATH inherits from another theme (like 'default').
|
||||
If DEFAULT_THEME_PATH is the same as CUSTOM_THEME_PATH, then collection
|
||||
is skipped and /static/themes will not exist.
|
||||
|
||||
This option is now marked as "deprecated" and will be removed in Newton or
|
||||
a later release. Themes are now controlled by AVAILABLE_THEMES.
|
||||
|
||||
``DROPDOWN_MAX_ITEMS``
|
||||
----------------------
|
||||
|
@ -120,5 +120,4 @@ A second theme is provided by default at
|
||||
``openstack_dashboard/themes/material/``. When adding new SCSS to horizon, you
|
||||
should check that it does not interfere with the Material theme. Images of how
|
||||
the Material theme should look can be found at https://bootswatch.com/paper/.
|
||||
To set up this theme, see the :ref:`custom_theme_path` entry in our settings
|
||||
documentation.
|
||||
This theme is now configured to run as the alternate theme within Horizon.
|
||||
|
@ -1,3 +1,5 @@
|
||||
{% load themes %}
|
||||
|
||||
<div class="text-center">
|
||||
<img class="splash-logo" src="{{ STATIC_URL }}dashboard/img/logo-splash.png">
|
||||
<img class="splash-logo" src={% themable_asset "img/logo-splash.png" %}>
|
||||
</div>
|
||||
|
164
horizon/themes.py
Normal file
164
horizon/themes.py
Normal file
@ -0,0 +1,164 @@
|
||||
# Copyright 2016 Hewlett Packard Enterprise Software, LLC
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
Allows Dynamic Theme Loading.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import threading
|
||||
|
||||
import django
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import SuspiciousFileOperation
|
||||
from django.template.engine import Engine
|
||||
from django.template.loaders.base import Loader as tLoaderCls
|
||||
from django.utils._os import safe_join # noqa
|
||||
|
||||
if django.VERSION >= (1, 9):
|
||||
from django.template.exceptions import TemplateDoesNotExist
|
||||
else:
|
||||
from django.template.base import TemplateDoesNotExist # noqa
|
||||
|
||||
|
||||
# Local thread storage to retrieve the currently set theme
|
||||
_local = threading.local()
|
||||
|
||||
|
||||
# Get the themes from settings
|
||||
def get_themes():
|
||||
return getattr(settings, 'AVAILABLE_THEMES', [])
|
||||
|
||||
|
||||
# Get the themes dir from settings
|
||||
def get_theme_dir():
|
||||
return getattr(settings, 'THEME_COLLECTION_DIR', 'themes')
|
||||
|
||||
|
||||
# Get the theme cookie name from settings
|
||||
def get_theme_cookie_name():
|
||||
return getattr(settings, 'THEME_COOKIE_NAME', 'theme')
|
||||
|
||||
|
||||
# Get the default theme
|
||||
def get_default_theme():
|
||||
return getattr(settings, 'DEFAULT_THEME', 'default')
|
||||
|
||||
|
||||
# Find the theme tuple
|
||||
def find_theme(theme_name):
|
||||
for each_theme in get_themes():
|
||||
if theme_name == each_theme[0]:
|
||||
return each_theme
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# Offline Context Generator
|
||||
def offline_context():
|
||||
for theme in get_themes():
|
||||
base_context = \
|
||||
getattr(
|
||||
settings,
|
||||
'HORIZON_COMPRESS_OFFLINE_CONTEXT_BASE',
|
||||
{}
|
||||
).copy()
|
||||
base_context['THEME'] = theme[0]
|
||||
base_context['THEME_DIR'] = get_theme_dir()
|
||||
yield base_context
|
||||
|
||||
|
||||
# A piece of middleware that stores the theme cookie value into
|
||||
# local thread storage so the template loader can access it
|
||||
class ThemeMiddleware(object):
|
||||
"""The Theme Middleware component. The custom template loaders
|
||||
don't have access to the request object, so we need to store
|
||||
the Cookie's theme value for use later in the Django chain.
|
||||
"""
|
||||
|
||||
def process_request(self, request):
|
||||
|
||||
# Determine which theme the user has configured and store in local
|
||||
# thread storage so that it persists to the custom template loader
|
||||
try:
|
||||
_local.theme = request.COOKIES[get_theme_cookie_name()]
|
||||
except KeyError:
|
||||
_local.theme = get_default_theme()
|
||||
|
||||
def process_response(self, request, response):
|
||||
try:
|
||||
delattr(_local, 'theme')
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class ThemeTemplateLoader(tLoaderCls):
|
||||
"""Themes can contain template overrides, so we need to check the
|
||||
theme directory first, before loading any of the standard templates.
|
||||
"""
|
||||
is_usable = True
|
||||
|
||||
def get_template_sources(self, template_name):
|
||||
|
||||
# If the cookie doesn't exist, set it to the default theme
|
||||
default_theme = get_default_theme()
|
||||
theme = getattr(_local, 'theme', default_theme)
|
||||
this_theme = find_theme(theme)
|
||||
|
||||
# If the theme is not valid, check the default theme ...
|
||||
if not this_theme:
|
||||
this_theme = find_theme(get_default_theme())
|
||||
|
||||
# If the theme is still not valid, then move along ...
|
||||
# these aren't the templates you are looking for
|
||||
if not this_theme:
|
||||
pass
|
||||
|
||||
try:
|
||||
if not template_name.startswith('/'):
|
||||
try:
|
||||
yield safe_join(
|
||||
'openstack_dashboard',
|
||||
this_theme[2],
|
||||
'templates',
|
||||
template_name
|
||||
)
|
||||
except SuspiciousFileOperation:
|
||||
yield os.path.join(
|
||||
this_theme[2], 'templates', template_name
|
||||
)
|
||||
|
||||
except UnicodeDecodeError:
|
||||
# The template dir name wasn't valid UTF-8.
|
||||
raise
|
||||
except ValueError:
|
||||
# The joined path was located outside of template_dir.
|
||||
pass
|
||||
|
||||
def load_template_source(self, template_name, template_dirs=None):
|
||||
for path in self.get_template_sources(template_name):
|
||||
try:
|
||||
with io.open(path, encoding=settings.FILE_CHARSET) as file:
|
||||
return file.read(), path
|
||||
except IOError:
|
||||
pass
|
||||
raise TemplateDoesNotExist(template_name)
|
||||
|
||||
|
||||
e = Engine()
|
||||
_loader = ThemeTemplateLoader(e)
|
@ -1,5 +1,3 @@
|
||||
@import "/custom/variables";
|
||||
|
||||
themepreview {
|
||||
|
||||
#source-button {
|
||||
|
@ -1,12 +1,19 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
{% load themes %}
|
||||
|
||||
{% block title %}
|
||||
{% trans "Theme Preview" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block page_header %}
|
||||
<h1>{{ skin }} <small>{{ skin_desc }}</small></h1>
|
||||
{% current_theme as current_theme %}
|
||||
{% themes as available_themes %}
|
||||
{% for theme in available_themes %}
|
||||
{% if current_theme == theme.0 %}
|
||||
<h1>{{ theme.1 }} <small>{{ theme.2 }}</small></h1>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
|
@ -12,7 +12,6 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from horizon import views
|
||||
@ -21,10 +20,3 @@ from horizon import views
|
||||
class IndexView(views.HorizonTemplateView):
|
||||
template_name = 'developer/theme_preview/index.html'
|
||||
page_title = _("Bootstrap Theme Preview")
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
theme_path = settings.CUSTOM_THEME_PATH
|
||||
context = super(IndexView, self).get_context_data(**kwargs)
|
||||
context['skin'] = theme_path.split('/')[-1]
|
||||
context['skin_desc'] = theme_path
|
||||
return context
|
||||
|
@ -1,3 +0,0 @@
|
||||
// Custom Theme Variables
|
||||
@import "/custom/variables";
|
||||
@import "/dashboard/scss/variables";
|
@ -1,9 +1,2 @@
|
||||
// Custom Theme Variables
|
||||
@import "/custom/variables";
|
||||
@import "/dashboard/scss/variables";
|
||||
|
||||
@import "users/users";
|
||||
@import "projects/projects";
|
||||
|
||||
// Custom Style Variables
|
||||
@import "/custom/styles";
|
||||
|
@ -1,9 +1 @@
|
||||
|
||||
// Custom Theme Variables
|
||||
@import "/custom/variables";
|
||||
|
||||
@import "/dashboard/scss/variables";
|
||||
@import "workflow/workflow";
|
||||
|
||||
// Custom Style Variables
|
||||
@import "/custom/styles";
|
||||
|
@ -25,7 +25,3 @@ ADD_ANGULAR_MODULES = [
|
||||
]
|
||||
|
||||
AUTO_DISCOVER_STATIC_FILES = True
|
||||
|
||||
ADD_SCSS_FILES = [
|
||||
'dashboard/admin/admin.scss'
|
||||
]
|
||||
|
@ -1,2 +1,2 @@
|
||||
# override the CUSTOM_THEME_PATH variable with this settings snippet
|
||||
# CUSTOM_THEME_PATH="themes/material"
|
||||
# AVAILABLE_THEMES=[('material', 'Material', 'themes/material')]
|
||||
|
@ -428,9 +428,13 @@ TIME_ZONE = "UTC"
|
||||
#TROVE_ADD_USER_PERMS = []
|
||||
#TROVE_ADD_DATABASE_PERMS = []
|
||||
|
||||
# Change this patch to the appropriate static directory containing
|
||||
# two files: _variables.scss and _styles.scss
|
||||
#CUSTOM_THEME_PATH = 'themes/default'
|
||||
# Change this patch to the appropriate list of tuples containing
|
||||
# a key, label and static directory containing two files:
|
||||
# _variables.scss and _styles.scss
|
||||
#AVAILABLE_THEMES = [
|
||||
# ('default', 'Default', 'themes/default'),
|
||||
# ('material', 'Material', 'themes/material'),
|
||||
#]
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
|
@ -21,11 +21,13 @@ import os
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
from django.utils.translation import pgettext_lazy
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from openstack_dashboard import exceptions
|
||||
from openstack_dashboard.static_settings import find_static_files # noqa
|
||||
from openstack_dashboard.static_settings import get_staticfiles_dirs # noqa
|
||||
from openstack_dashboard import theme_settings
|
||||
|
||||
|
||||
warnings.formatwarning = lambda message, category, *args, **kwargs: \
|
||||
@ -105,6 +107,7 @@ MIDDLEWARE_CLASSES = (
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
|
||||
'horizon.middleware.HorizonMiddleware',
|
||||
'horizon.themes.ThemeMiddleware',
|
||||
'django.middleware.locale.LocaleMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
)
|
||||
@ -121,6 +124,7 @@ TEMPLATE_CONTEXT_PROCESSORS = (
|
||||
)
|
||||
|
||||
TEMPLATE_LOADERS = (
|
||||
'horizon.themes.ThemeTemplateLoader',
|
||||
('django.template.loaders.cached.Loader', (
|
||||
'django.template.loaders.filesystem.Loader',
|
||||
'django.template.loaders.app_directories.Loader',
|
||||
@ -257,10 +261,31 @@ SECURITY_GROUP_RULES = {
|
||||
|
||||
ADD_INSTALLED_APPS = []
|
||||
|
||||
# directory for custom theme, set as default.
|
||||
# It can be overridden in local_settings.py
|
||||
DEFAULT_THEME_PATH = 'themes/default'
|
||||
CUSTOM_THEME_PATH = DEFAULT_THEME_PATH
|
||||
# Deprecated Theme Settings
|
||||
CUSTOM_THEME_PATH = None
|
||||
DEFAULT_THEME_PATH = None
|
||||
|
||||
# 'key', 'label', 'path'
|
||||
AVAILABLE_THEMES = [
|
||||
(
|
||||
'default',
|
||||
pgettext_lazy('Default style theme', 'Default'),
|
||||
'themes/default'
|
||||
), (
|
||||
'material',
|
||||
pgettext_lazy("Google's Material Design style theme", "Material"),
|
||||
'themes/material'
|
||||
),
|
||||
]
|
||||
|
||||
# The default theme if no cookie is present
|
||||
DEFAULT_THEME = 'default'
|
||||
|
||||
# Theme Static Directory
|
||||
THEME_COLLECTION_DIR = 'themes'
|
||||
|
||||
# Theme Cookie Name
|
||||
THEME_COOKIE_NAME = 'theme'
|
||||
|
||||
try:
|
||||
from local.local_settings import * # noqa
|
||||
@ -298,39 +323,26 @@ if STATIC_ROOT is None:
|
||||
if STATIC_URL is None:
|
||||
STATIC_URL = WEBROOT + 'static/'
|
||||
|
||||
STATICFILES_DIRS = get_staticfiles_dirs(STATIC_URL)
|
||||
|
||||
CUSTOM_THEME = os.path.join(ROOT_PATH, CUSTOM_THEME_PATH)
|
||||
|
||||
# If a custom template directory exists within our custom theme, then prepend
|
||||
# it to our first-come, first-serve TEMPLATE_DIRS
|
||||
if os.path.exists(os.path.join(CUSTOM_THEME, 'templates')):
|
||||
TEMPLATE_DIRS = \
|
||||
(os.path.join(CUSTOM_THEME, 'templates'),) + TEMPLATE_DIRS
|
||||
|
||||
# Only expose the subdirectory 'static' if it exists from a custom theme,
|
||||
# allowing other logic to live with a theme that we might not want to expose
|
||||
# statically
|
||||
if os.path.exists(os.path.join(CUSTOM_THEME, 'static')):
|
||||
CUSTOM_THEME = os.path.join(CUSTOM_THEME, 'static')
|
||||
|
||||
# Only collect and expose the default theme if the user chose to set a
|
||||
# different theme
|
||||
if DEFAULT_THEME_PATH != CUSTOM_THEME_PATH:
|
||||
STATICFILES_DIRS.append(
|
||||
('themes/default', os.path.join(ROOT_PATH, DEFAULT_THEME_PATH)),
|
||||
)
|
||||
|
||||
STATICFILES_DIRS.append(
|
||||
('custom', CUSTOM_THEME),
|
||||
AVAILABLE_THEMES, DEFAULT_THEME = theme_settings.get_available_themes(
|
||||
AVAILABLE_THEMES,
|
||||
CUSTOM_THEME_PATH,
|
||||
DEFAULT_THEME_PATH,
|
||||
DEFAULT_THEME
|
||||
)
|
||||
|
||||
# Load the subdirectory 'img' of a custom theme if it exists, thereby allowing
|
||||
# very granular theme overrides of all dashboard img files using the first-come
|
||||
# first-serve filesystem loader.
|
||||
if os.path.exists(os.path.join(CUSTOM_THEME, 'img')):
|
||||
STATICFILES_DIRS.insert(0, ('dashboard/img',
|
||||
os.path.join(CUSTOM_THEME, 'img')))
|
||||
STATICFILES_DIRS = get_staticfiles_dirs(STATIC_URL) + \
|
||||
theme_settings.get_theme_static_dirs(
|
||||
AVAILABLE_THEMES,
|
||||
THEME_COLLECTION_DIR,
|
||||
ROOT_PATH)
|
||||
|
||||
if CUSTOM_THEME_PATH is not None:
|
||||
logging.warning("CUSTOM_THEME_PATH has been deprecated. Please convert "
|
||||
"your settings to make use of AVAILABLE_THEMES.")
|
||||
|
||||
if DEFAULT_THEME_PATH is not None:
|
||||
logging.warning("DEFAULT_THEME_PATH has been deprecated. Please convert "
|
||||
"your settings to make use of AVAILABLE_THEMES.")
|
||||
|
||||
# populate HORIZON_CONFIG with auto-discovered JavaScript sources, mock files,
|
||||
# specs files and external templates.
|
||||
@ -367,13 +379,16 @@ INSTALLED_APPS[0:0] = ADD_INSTALLED_APPS
|
||||
from openstack_auth import policy
|
||||
POLICY_CHECK_FUNCTION = policy.check
|
||||
|
||||
# Add HORIZON_CONFIG to the context information for offline compression
|
||||
COMPRESS_OFFLINE_CONTEXT = {
|
||||
# This base context objects gets added to the offline context generator
|
||||
# for each theme configured.
|
||||
HORIZON_COMPRESS_OFFLINE_CONTEXT_BASE = {
|
||||
'WEBROOT': WEBROOT,
|
||||
'STATIC_URL': STATIC_URL,
|
||||
'HORIZON_CONFIG': HORIZON_CONFIG,
|
||||
'HORIZON_CONFIG': HORIZON_CONFIG
|
||||
}
|
||||
|
||||
COMPRESS_OFFLINE_CONTEXT = 'horizon.themes.offline_context'
|
||||
|
||||
if DEBUG:
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
|
1
openstack_dashboard/static/app/_app.scss
Normal file
1
openstack_dashboard/static/app/_app.scss
Normal file
@ -0,0 +1 @@
|
||||
@import "core/core";
|
@ -1,5 +0,0 @@
|
||||
// Custom Theme Variables
|
||||
@import "/custom/variables";
|
||||
@import "/dashboard/scss/variables";
|
||||
|
||||
@import "core/core";
|
@ -1,7 +1,3 @@
|
||||
/* This import is required for using the current theme variables as value
|
||||
to our variables */
|
||||
@import "/custom/variables";
|
||||
|
||||
/* When used with Horizon via Django, this value is set automatically from
|
||||
settings.py and is added dynamically to the namespace through
|
||||
horizon/utils/scss_filter.py */
|
||||
|
@ -1,6 +1,3 @@
|
||||
// Custom Theme Variables
|
||||
@import "/custom/variables";
|
||||
|
||||
// Horizon Variables
|
||||
@import "variables";
|
||||
|
||||
|
@ -1,25 +1,29 @@
|
||||
{% load compress %}
|
||||
{% load themes %}
|
||||
|
||||
{% compress css %}
|
||||
<link href='{{ STATIC_URL }}horizon/lib/bootstrap_datepicker/datepicker3.css' type='text/css' media='screen' rel='stylesheet' />
|
||||
<link href='{{ STATIC_URL }}horizon/lib/rickshaw.css' type='text/css' media='screen' rel='stylesheet' />
|
||||
{% endcompress %}
|
||||
|
||||
|
||||
{% current_theme as current_theme %}
|
||||
{% theme_dir as theme_dir %}
|
||||
|
||||
{% comment %}
|
||||
We want to have separate compressed css files for horizon.scss and dashboard.scss.
|
||||
The reason for it is based on the fact that IE9 has a limit on the number of css rules
|
||||
that can be parsed in a single css file. The limit is 4095 = (4k - 1). This causes some
|
||||
css rules getting cut off if one css file to get more than 4k rules inside.
|
||||
The following 'include' is used to allow all scss files to share the same variable namespace
|
||||
and also have access to ALL styles so that we can allow @extend functionality to persist.
|
||||
|
||||
If you wish to add new scss files, it is recommended that you add them from within the
|
||||
themes/themes.scss template file.
|
||||
{% endcomment %}
|
||||
|
||||
{% load compress %}
|
||||
|
||||
{% with THEME=current_theme THEME_DIR=theme_dir %}
|
||||
{% compress css %}
|
||||
<link href='{{ STATIC_URL }}horizon/lib/bootstrap_datepicker/datepicker3.css' type='text/scss' media='screen' rel='stylesheet' />
|
||||
<link href='{{ STATIC_URL }}horizon/lib/rickshaw.css' type='text/scss' media='screen' rel='stylesheet' />
|
||||
<link href='{{ STATIC_URL }}dashboard/scss/horizon.scss' type='text/scss' media='screen' rel='stylesheet' />
|
||||
<style type="text/scss">
|
||||
{% include 'themes/themes.scss' %}
|
||||
</style>
|
||||
{% endcompress %}
|
||||
{% endwith %}
|
||||
|
||||
{% compress css %}
|
||||
<link href='{{ STATIC_URL }}app/app.scss' type='text/scss' media='screen' rel='stylesheet' />
|
||||
|
||||
{% for file in HORIZON_CONFIG.scss_files %}
|
||||
<link href='{{ STATIC_URL }}{{ file }}' type='text/scss' media='screen' rel='stylesheet'/>
|
||||
{% endfor %}
|
||||
|
||||
{% endcompress %}
|
||||
|
||||
<link rel="shortcut icon" href="{{ STATIC_URL }}dashboard/img/favicon.ico"/>
|
||||
<link rel="shortcut icon" href="{% themable_asset 'img/favicon.ico' %}"/>
|
||||
|
@ -1,5 +1,6 @@
|
||||
{% load branding %}
|
||||
{% load themes %}
|
||||
|
||||
<a class="navbar-brand" href="{% site_branding_link %}" target="_self">
|
||||
<img class="openstack-logo" src="{{ STATIC_URL }}dashboard/img/logo.png" alt="{% site_branding %}">
|
||||
<img class="openstack-logo" src="{% themable_asset 'img/logo.png' %}" alt="{% site_branding %}">
|
||||
</a>
|
||||
|
43
openstack_dashboard/templates/header/_theme_list.html
Normal file
43
openstack_dashboard/templates/header/_theme_list.html
Normal file
@ -0,0 +1,43 @@
|
||||
{% load i18n %}
|
||||
{% load themes %}
|
||||
|
||||
|
||||
{% theme_cookie as theme_cookie %}
|
||||
<ul class="dropdown-menu theme-picker">
|
||||
<li class="dropdown-header">{% trans "Themes:" %}</li>
|
||||
{% current_theme as current_theme %}
|
||||
{% for theme in available_themes %}
|
||||
<li>
|
||||
<a data-theme="{{ theme.0 }}"
|
||||
class="theme-{{ theme.0 }} theme-picker-item {% if current_theme == theme.0 %}dropdown-selected disabled{% else %}openstack-spin{% endif %}"
|
||||
href="#"
|
||||
target="_self">
|
||||
<span class="fa fa-check dropdown-selected-icon"></span>
|
||||
<span class="dropdown-title">{{ theme.1 }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
|
||||
horizon.addInitFunction(function() {
|
||||
|
||||
$(document).on('click', '.theme-picker-item', function(e) {
|
||||
var $this = $(this);
|
||||
|
||||
if($this.hasClass('disabled')) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
var CookieDate = new Date;
|
||||
CookieDate.setFullYear(CookieDate.getFullYear( ) +10);
|
||||
|
||||
document.cookie = '{{ theme_cookie }}=' + $this.data('theme') + '; path=/; expires=' + CookieDate.toGMTString( ) + ';';
|
||||
document.location.reload();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
@ -1,16 +1,17 @@
|
||||
{% load i18n %}
|
||||
{% load themes %}
|
||||
|
||||
{% if not_list %}
|
||||
<div class="dropdown">
|
||||
<div class="dropdown user-menu">
|
||||
{% else %}
|
||||
<li class="dropdown">
|
||||
<li class="dropdown user-menu">
|
||||
{% endif %}
|
||||
<a data-toggle="dropdown" href="#" class="dropdown-toggle" role="button" aria-expanded="false">
|
||||
<span class="fa fa-user"></span>
|
||||
<span class="user-name">{{ request.user.username }}</span>
|
||||
<span class="fa fa-caret-down"></span>
|
||||
</a>
|
||||
<ul id="editor_list" class="dropdown-menu dropdown-menu-right">
|
||||
<ul id="editor_list" class="dropdown-menu dropdown-menu-right selection-menu">
|
||||
<li>
|
||||
<a href="{% url 'horizon:settings:user:index' %}" target="_self">
|
||||
<span class="fa fa-cog"></span>
|
||||
@ -33,6 +34,13 @@
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% themes as available_themes %}
|
||||
{% if available_themes and available_themes|length > 1 %}
|
||||
<li class="divider"></li>
|
||||
<li>
|
||||
{% include 'header/_theme_list.html' %}
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="divider"></li>
|
||||
<li>
|
||||
<a href="{% url 'logout' %}" target="_self">
|
||||
|
15
openstack_dashboard/templates/themes/themes.scss
Normal file
15
openstack_dashboard/templates/themes/themes.scss
Normal file
@ -0,0 +1,15 @@
|
||||
// My Themes
|
||||
@import "/{{ THEME_DIR }}/{{ THEME }}/variables";
|
||||
|
||||
// Horizon
|
||||
@import "/dashboard/scss/horizon.scss";
|
||||
|
||||
// Angular
|
||||
@import "/app/app";
|
||||
|
||||
{% for file in HORIZON_CONFIG.scss_files %}
|
||||
@import '/{{ file }}';
|
||||
{% endfor %}
|
||||
|
||||
// Custom Styles
|
||||
@import "/{{ THEME_DIR }}/{{ THEME }}/styles";
|
91
openstack_dashboard/templatetags/themes.py
Normal file
91
openstack_dashboard/templatetags/themes.py
Normal file
@ -0,0 +1,91 @@
|
||||
# Copyright 2016 Hewlett Packard Enterprise Software, LLC
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
import os
|
||||
from six.moves.urllib.request import pathname2url
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.staticfiles.storage import staticfiles_storage
|
||||
from django import template
|
||||
|
||||
from horizon import themes as hz_themes
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
def get_theme(request):
|
||||
|
||||
this_theme = hz_themes.get_default_theme()
|
||||
try:
|
||||
theme = request.COOKIES[hz_themes.get_theme_cookie_name()]
|
||||
for each_theme in hz_themes.get_themes():
|
||||
if theme == each_theme[0]:
|
||||
this_theme = each_theme[0]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return this_theme
|
||||
|
||||
|
||||
def find_asset(theme, asset):
|
||||
|
||||
theme_path = ''
|
||||
for name, label, path in hz_themes.get_themes():
|
||||
if theme == name:
|
||||
theme_path = path
|
||||
|
||||
theme_path = os.path.join(settings.ROOT_PATH, theme_path)
|
||||
|
||||
# If there is a 'static' subdir of the theme, then use
|
||||
# that as the theme's asset root path
|
||||
static_path = os.path.join(theme_path, 'static')
|
||||
if os.path.exists(static_path):
|
||||
theme_path = static_path
|
||||
|
||||
# The full path to the asset requested
|
||||
asset_path = os.path.join(theme_path, asset)
|
||||
if os.path.exists(asset_path):
|
||||
return_path = os.path.join(hz_themes.get_theme_dir(), theme, asset)
|
||||
else:
|
||||
return_path = os.path.join('dashboard', asset)
|
||||
|
||||
return staticfiles_storage.url(pathname2url(return_path))
|
||||
|
||||
|
||||
@register.assignment_tag()
|
||||
def themes():
|
||||
return hz_themes.get_themes()
|
||||
|
||||
|
||||
@register.assignment_tag()
|
||||
def theme_cookie():
|
||||
return hz_themes.get_theme_cookie_name()
|
||||
|
||||
|
||||
@register.assignment_tag()
|
||||
def theme_dir():
|
||||
return hz_themes.get_theme_dir()
|
||||
|
||||
|
||||
@register.assignment_tag(takes_context=True)
|
||||
def current_theme(context):
|
||||
return get_theme(context.request)
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def themable_asset(context, asset):
|
||||
return find_asset(get_theme(context.request), asset)
|
103
openstack_dashboard/theme_settings.py
Normal file
103
openstack_dashboard/theme_settings.py
Normal file
@ -0,0 +1,103 @@
|
||||
# Copyright 2016 Hewlett Packard Enterprise Software, LLC
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from django.utils.translation import pgettext_lazy
|
||||
|
||||
|
||||
def get_theme_static_dirs(available_themes, collection_dir, root):
|
||||
static_dirs = []
|
||||
# Collect and expose the themes that have been configured
|
||||
for theme in available_themes:
|
||||
theme_name, theme_label, theme_path = theme
|
||||
theme_url = os.path.join(collection_dir, theme_name)
|
||||
theme_path = os.path.join(root, theme_path)
|
||||
if os.path.exists(os.path.join(theme_path, 'static')):
|
||||
# Only expose the subdirectory 'static' if it exists from a custom
|
||||
# theme, allowing other logic to live with a theme that we might
|
||||
# not want to expose statically
|
||||
theme_path = os.path.join(theme_path, 'static')
|
||||
|
||||
static_dirs.append(
|
||||
(theme_url, theme_path),
|
||||
)
|
||||
|
||||
return static_dirs
|
||||
|
||||
|
||||
def get_available_themes(available_themes, custom_path, default_path,
|
||||
default_theme):
|
||||
new_theme_list = []
|
||||
# We can only support one path at a time, because of static file
|
||||
# collection.
|
||||
custom_ndx = -1
|
||||
default_ndx = -1
|
||||
default_theme_ndx = -1
|
||||
for ndx, each_theme in enumerate(available_themes):
|
||||
|
||||
# Maintain Backward Compatibility for CUSTOM_THEME_PATH
|
||||
if custom_path:
|
||||
if each_theme[2] == custom_path:
|
||||
custom_ndx = ndx
|
||||
|
||||
# Maintain Backward Compatibility for DEFAULT_THEME_PATH
|
||||
if default_path:
|
||||
if each_theme[0] == 'default':
|
||||
default_ndx = ndx
|
||||
each_theme = (
|
||||
'default',
|
||||
pgettext_lazy('Default style theme', 'Default'),
|
||||
default_path
|
||||
)
|
||||
|
||||
# Make sure that DEFAULT_THEME is configured for use
|
||||
if each_theme[0] == default_theme:
|
||||
default_theme_ndx = ndx
|
||||
|
||||
new_theme_list.append(each_theme)
|
||||
|
||||
if custom_ndx != -1:
|
||||
# If CUSTOM_THEME_PATH is set, then we should set that as the default
|
||||
# theme to make sure that upgrading Horizon doesn't jostle anyone
|
||||
default_theme = available_themes[custom_ndx][0]
|
||||
logging.warning("Your AVAILABLE_THEMES already contains your "
|
||||
"CUSTOM_THEME_PATH, therefore using configuration in "
|
||||
"AVAILABLE_THEMES for %s." % custom_path)
|
||||
|
||||
elif custom_path is not None:
|
||||
new_theme_list.append(
|
||||
('custom',
|
||||
pgettext_lazy('Custom style theme', 'Custom'),
|
||||
custom_path)
|
||||
)
|
||||
default_theme = 'custom'
|
||||
|
||||
# If 'default' isn't present at all, add it with the default_path
|
||||
if default_ndx == -1 and default_path is not None:
|
||||
new_theme_list.append(
|
||||
('default',
|
||||
pgettext_lazy('Default style theme', 'Default'),
|
||||
default_path)
|
||||
)
|
||||
|
||||
# If default is not configured, we have to set one,
|
||||
# just grab the first theme
|
||||
if default_theme_ndx == -1 and custom_ndx == -1:
|
||||
default_theme = available_themes[0][0]
|
||||
|
||||
return new_theme_list, default_theme
|
@ -1,5 +1,5 @@
|
||||
// Override the web font path ... we want to set this ourselves
|
||||
$web-font-path: "-";
|
||||
$web-font-path: $static_url + "/horizon/lib/roboto_fontface/css/roboto-fontface.css";
|
||||
$roboto-font-path: $static_url + "/horizon/lib/roboto_fontface/fonts";
|
||||
|
||||
@import "variable_customizations";
|
||||
|
@ -1,8 +1,8 @@
|
||||
{% load branding i18n %}
|
||||
{% load context_selection %}
|
||||
{% load compress %}
|
||||
{% load themes %}
|
||||
|
||||
<nav class="navbar-inverse navbar-fixed-top">
|
||||
<nav class="navbar-inverse material-header navbar-fixed-top">
|
||||
<div class="container-fluid">
|
||||
<!-- Brand and toggle get grouped for better mobile display -->
|
||||
<div class="navbar-header">
|
||||
@ -16,9 +16,8 @@
|
||||
<button class="md-hamburger-trigger">
|
||||
<span class="md-hamburger-layer md-hamburger-menu"></span>
|
||||
</button>
|
||||
{% compress js inline %}
|
||||
<script src='{{ STATIC_URL }}custom/js/material.hamburger.js'></script>
|
||||
{% endcompress %}
|
||||
{% theme_dir as theme_dir %}
|
||||
<script src='{{ STATIC_URL }}{{ theme_dir }}/material/js/material.hamburger.js'></script>
|
||||
</div>
|
||||
{% include "header/_brand.html" %}
|
||||
</div>
|
||||
|
12
releasenotes/notes/dynamic-themes-b6b02238e47b99f8.yaml
Normal file
12
releasenotes/notes/dynamic-themes-b6b02238e47b99f8.yaml
Normal file
@ -0,0 +1,12 @@
|
||||
---
|
||||
features:
|
||||
- Horizon can be configured to run with multiple
|
||||
themes available at run time. A new selection
|
||||
widget is available through the user menu. It
|
||||
uses a browser cookie to allow users to toggle
|
||||
between the configured themes. By default,
|
||||
Horizon is configured with the two themes
|
||||
available, 'default' and 'material'.
|
||||
deprecations:
|
||||
- The setting CUSTOM_THEME_PATH is now deprecated.
|
||||
- The setting DEFAULT_THEME_PATH is now deprecated.
|
Loading…
Reference in New Issue
Block a user