Compare commits
	
		
			29 Commits
		
	
	
		
			tmp-video-
			...
			wip-refact
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 77e3c476f0 | |||
| 842ddaeab0 | |||
| 85e5cb4f71 | |||
| 6648f8d074 | |||
| a5bc36b1cf | |||
| e56b3ec61f | |||
| 9624f6bd76 | |||
| 4e5a53a19b | |||
| fbc7c0fce7 | |||
| bb483e72aa | |||
| baf27fa560 | |||
| 845ba953cb | |||
| e5b7905a5c | |||
| 88c0ef0e7c | |||
| f8d992400e | |||
| 263d68071e | |||
| 0f7f7d5a66 | |||
| 6b29c70212 | |||
| 07670dce96 | |||
| fe288b1cc2 | |||
| 2e9555e160 | |||
| b0311af6b5 | |||
| 35a22cab4b | |||
| 0055633732 | |||
| 78b186c8e4 | |||
| 232321cc2c | |||
| a6d662b690 | |||
| 32c7ffbc99 | |||
| cfcc629b61 | 
							
								
								
									
										2176
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2176
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -1,18 +1,12 @@
 | 
				
			|||||||
import base64
 | 
					import base64
 | 
				
			||||||
import functools
 | 
					 | 
				
			||||||
import logging
 | 
					import logging
 | 
				
			||||||
import typing
 | 
					 | 
				
			||||||
import urllib.parse
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
import pymongo.errors
 | 
					import pymongo.errors
 | 
				
			||||||
import werkzeug.exceptions as wz_exceptions
 | 
					import werkzeug.exceptions as wz_exceptions
 | 
				
			||||||
from bson import ObjectId
 | 
					 | 
				
			||||||
from flask import current_app, Blueprint, request
 | 
					from flask import current_app, Blueprint, request
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import pillar.markdown
 | 
					from pillar.api.nodes import hooks
 | 
				
			||||||
from pillar.api.activities import activity_subscribe, activity_object_add
 | 
					from pillar.api.nodes.hooks import short_link_info
 | 
				
			||||||
from pillar.api.node_types import PILLAR_NAMED_NODE_TYPES
 | 
					 | 
				
			||||||
from pillar.api.file_storage_backends.gcs import update_file_name
 | 
					 | 
				
			||||||
from pillar.api.utils import str2id, jsonify
 | 
					from pillar.api.utils import str2id, jsonify
 | 
				
			||||||
from pillar.api.utils.authorization import check_permissions, require_login
 | 
					from pillar.api.utils.authorization import check_permissions, require_login
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -21,40 +15,6 @@ blueprint = Blueprint('nodes_api', __name__)
 | 
				
			|||||||
ROLES_FOR_SHARING = {'subscriber', 'demo'}
 | 
					ROLES_FOR_SHARING = {'subscriber', 'demo'}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def only_for_node_type_decorator(*required_node_type_names):
 | 
					 | 
				
			||||||
    """Returns a decorator that checks its first argument's node type.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    If the node type is not of the required node type, returns None,
 | 
					 | 
				
			||||||
    otherwise calls the wrapped function.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    >>> deco = only_for_node_type_decorator('comment')
 | 
					 | 
				
			||||||
    >>> @deco
 | 
					 | 
				
			||||||
    ... def handle_comment(node): pass
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    >>> deco = only_for_node_type_decorator('comment', 'post')
 | 
					 | 
				
			||||||
    >>> @deco
 | 
					 | 
				
			||||||
    ... def handle_comment_or_post(node): pass
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Convert to a set for efficient 'x in required_node_type_names' queries.
 | 
					 | 
				
			||||||
    required_node_type_names = set(required_node_type_names)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def only_for_node_type(wrapped):
 | 
					 | 
				
			||||||
        @functools.wraps(wrapped)
 | 
					 | 
				
			||||||
        def wrapper(node, *args, **kwargs):
 | 
					 | 
				
			||||||
            if node.get('node_type') not in required_node_type_names:
 | 
					 | 
				
			||||||
                return
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            return wrapped(node, *args, **kwargs)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return wrapper
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    only_for_node_type.__doc__ = "Decorator, immediately returns when " \
 | 
					 | 
				
			||||||
                                 "the first argument is not of type %s." % required_node_type_names
 | 
					 | 
				
			||||||
    return only_for_node_type
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@blueprint.route('/<node_id>/share', methods=['GET', 'POST'])
 | 
					@blueprint.route('/<node_id>/share', methods=['GET', 'POST'])
 | 
				
			||||||
@require_login(require_roles=ROLES_FOR_SHARING)
 | 
					@require_login(require_roles=ROLES_FOR_SHARING)
 | 
				
			||||||
def share_node(node_id):
 | 
					def share_node(node_id):
 | 
				
			||||||
@@ -94,13 +54,31 @@ def share_node(node_id):
 | 
				
			|||||||
@blueprint.route('/tagged/<tag>')
 | 
					@blueprint.route('/tagged/<tag>')
 | 
				
			||||||
def tagged(tag=''):
 | 
					def tagged(tag=''):
 | 
				
			||||||
    """Return all tagged nodes of public projects as JSON."""
 | 
					    """Return all tagged nodes of public projects as JSON."""
 | 
				
			||||||
 | 
					    from pillar.auth import current_user
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # We explicitly register the tagless endpoint to raise a 404, otherwise the PATCH
 | 
					    # We explicitly register the tagless endpoint to raise a 404, otherwise the PATCH
 | 
				
			||||||
    # handler on /api/nodes/<node_id> will return a 405 Method Not Allowed.
 | 
					    # handler on /api/nodes/<node_id> will return a 405 Method Not Allowed.
 | 
				
			||||||
    if not tag:
 | 
					    if not tag:
 | 
				
			||||||
        raise wz_exceptions.NotFound()
 | 
					        raise wz_exceptions.NotFound()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return _tagged(tag)
 | 
					    # Build the (cached) list of tagged nodes
 | 
				
			||||||
 | 
					    agg_list = _tagged(tag)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # If the user is anonymous, no more information is needed and we return
 | 
				
			||||||
 | 
					    if current_user.is_anonymous:
 | 
				
			||||||
 | 
					        return jsonify(agg_list)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # If the user is authenticated, attach view_progress for video assets
 | 
				
			||||||
 | 
					    view_progress = current_user.nodes['view_progress']
 | 
				
			||||||
 | 
					    for node in agg_list:
 | 
				
			||||||
 | 
					        node_id = str(node['_id'])
 | 
				
			||||||
 | 
					        # View progress should be added only for nodes of type 'asset' and
 | 
				
			||||||
 | 
					        # with content_type 'video', only if the video was already in the watched
 | 
				
			||||||
 | 
					        # list for the current user.
 | 
				
			||||||
 | 
					        if node_id in view_progress:
 | 
				
			||||||
 | 
					            node['view_progress'] = view_progress[node_id]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return jsonify(agg_list)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def _tagged(tag: str):
 | 
					def _tagged(tag: str):
 | 
				
			||||||
@@ -129,7 +107,8 @@ def _tagged(tag: str):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        {'$sort': {'_created': -1}}
 | 
					        {'$sort': {'_created': -1}}
 | 
				
			||||||
    ])
 | 
					    ])
 | 
				
			||||||
    return jsonify(list(agg))
 | 
					
 | 
				
			||||||
 | 
					    return list(agg)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def generate_and_store_short_code(node):
 | 
					def generate_and_store_short_code(node):
 | 
				
			||||||
@@ -207,283 +186,6 @@ def create_short_code(node) -> str:
 | 
				
			|||||||
    return short_code
 | 
					    return short_code
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def short_link_info(short_code):
 | 
					 | 
				
			||||||
    """Returns the short link info in a dict."""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    short_link = urllib.parse.urljoin(
 | 
					 | 
				
			||||||
        current_app.config['SHORT_LINK_BASE_URL'], short_code)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return {
 | 
					 | 
				
			||||||
        'short_code': short_code,
 | 
					 | 
				
			||||||
        'short_link': short_link,
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def before_replacing_node(item, original):
 | 
					 | 
				
			||||||
    check_permissions('nodes', original, 'PUT')
 | 
					 | 
				
			||||||
    update_file_name(item)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def after_replacing_node(item, original):
 | 
					 | 
				
			||||||
    """Push an update to the Algolia index when a node item is updated. If the
 | 
					 | 
				
			||||||
    project is private, prevent public indexing.
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    from pillar.celery import search_index_tasks as index
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    projects_collection = current_app.data.driver.db['projects']
 | 
					 | 
				
			||||||
    project = projects_collection.find_one({'_id': item['project']})
 | 
					 | 
				
			||||||
    if project.get('is_private', False):
 | 
					 | 
				
			||||||
        # Skip index updating and return
 | 
					 | 
				
			||||||
        return
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    status = item['properties'].get('status', 'unpublished')
 | 
					 | 
				
			||||||
    node_id = str(item['_id'])
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if status == 'published':
 | 
					 | 
				
			||||||
        index.node_save.delay(node_id)
 | 
					 | 
				
			||||||
    else:
 | 
					 | 
				
			||||||
        index.node_delete.delay(node_id)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def before_inserting_nodes(items):
 | 
					 | 
				
			||||||
    """Before inserting a node in the collection we check if the user is allowed
 | 
					 | 
				
			||||||
    and we append the project id to it.
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    from pillar.auth import current_user
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    nodes_collection = current_app.data.driver.db['nodes']
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def find_parent_project(node):
 | 
					 | 
				
			||||||
        """Recursive function that finds the ultimate parent of a node."""
 | 
					 | 
				
			||||||
        if node and 'parent' in node:
 | 
					 | 
				
			||||||
            parent = nodes_collection.find_one({'_id': node['parent']})
 | 
					 | 
				
			||||||
            return find_parent_project(parent)
 | 
					 | 
				
			||||||
        if node:
 | 
					 | 
				
			||||||
            return node
 | 
					 | 
				
			||||||
        else:
 | 
					 | 
				
			||||||
            return None
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    for item in items:
 | 
					 | 
				
			||||||
        check_permissions('nodes', item, 'POST')
 | 
					 | 
				
			||||||
        if 'parent' in item and 'project' not in item:
 | 
					 | 
				
			||||||
            parent = nodes_collection.find_one({'_id': item['parent']})
 | 
					 | 
				
			||||||
            project = find_parent_project(parent)
 | 
					 | 
				
			||||||
            if project:
 | 
					 | 
				
			||||||
                item['project'] = project['_id']
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # Default the 'user' property to the current user.
 | 
					 | 
				
			||||||
        item.setdefault('user', current_user.user_id)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def after_inserting_nodes(items):
 | 
					 | 
				
			||||||
    for item in items:
 | 
					 | 
				
			||||||
        # Skip subscriptions for first level items (since the context is not a
 | 
					 | 
				
			||||||
        # node, but a project).
 | 
					 | 
				
			||||||
        # TODO: support should be added for mixed context
 | 
					 | 
				
			||||||
        if 'parent' not in item:
 | 
					 | 
				
			||||||
            return
 | 
					 | 
				
			||||||
        context_object_id = item['parent']
 | 
					 | 
				
			||||||
        if item['node_type'] == 'comment':
 | 
					 | 
				
			||||||
            nodes_collection = current_app.data.driver.db['nodes']
 | 
					 | 
				
			||||||
            parent = nodes_collection.find_one({'_id': item['parent']})
 | 
					 | 
				
			||||||
            # Always subscribe to the parent node
 | 
					 | 
				
			||||||
            activity_subscribe(item['user'], 'node', item['parent'])
 | 
					 | 
				
			||||||
            if parent['node_type'] == 'comment':
 | 
					 | 
				
			||||||
                # If the parent is a comment, we provide its own parent as
 | 
					 | 
				
			||||||
                # context. We do this in order to point the user to an asset
 | 
					 | 
				
			||||||
                # or group when viewing the notification.
 | 
					 | 
				
			||||||
                verb = 'replied'
 | 
					 | 
				
			||||||
                context_object_id = parent['parent']
 | 
					 | 
				
			||||||
                # Subscribe to the parent of the parent comment (post or group)
 | 
					 | 
				
			||||||
                activity_subscribe(item['user'], 'node', parent['parent'])
 | 
					 | 
				
			||||||
            else:
 | 
					 | 
				
			||||||
                activity_subscribe(item['user'], 'node', item['_id'])
 | 
					 | 
				
			||||||
                verb = 'commented'
 | 
					 | 
				
			||||||
        elif item['node_type'] in PILLAR_NAMED_NODE_TYPES:
 | 
					 | 
				
			||||||
            verb = 'posted'
 | 
					 | 
				
			||||||
            activity_subscribe(item['user'], 'node', item['_id'])
 | 
					 | 
				
			||||||
        else:
 | 
					 | 
				
			||||||
            # Don't automatically create activities for non-Pillar node types,
 | 
					 | 
				
			||||||
            # as we don't know what would be a suitable verb (among other things).
 | 
					 | 
				
			||||||
            continue
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        activity_object_add(
 | 
					 | 
				
			||||||
            item['user'],
 | 
					 | 
				
			||||||
            verb,
 | 
					 | 
				
			||||||
            'node',
 | 
					 | 
				
			||||||
            item['_id'],
 | 
					 | 
				
			||||||
            'node',
 | 
					 | 
				
			||||||
            context_object_id
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def deduct_content_type(node_doc, original=None):
 | 
					 | 
				
			||||||
    """Deduct the content type from the attached file, if any."""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if node_doc['node_type'] != 'asset':
 | 
					 | 
				
			||||||
        log.debug('deduct_content_type: called on node type %r, ignoring', node_doc['node_type'])
 | 
					 | 
				
			||||||
        return
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    node_id = node_doc.get('_id')
 | 
					 | 
				
			||||||
    try:
 | 
					 | 
				
			||||||
        file_id = ObjectId(node_doc['properties']['file'])
 | 
					 | 
				
			||||||
    except KeyError:
 | 
					 | 
				
			||||||
        if node_id is None:
 | 
					 | 
				
			||||||
            # Creation of a file-less node is allowed, but updates aren't.
 | 
					 | 
				
			||||||
            return
 | 
					 | 
				
			||||||
        log.warning('deduct_content_type: Asset without properties.file, rejecting.')
 | 
					 | 
				
			||||||
        raise wz_exceptions.UnprocessableEntity('Missing file property for asset node')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    files = current_app.data.driver.db['files']
 | 
					 | 
				
			||||||
    file_doc = files.find_one({'_id': file_id},
 | 
					 | 
				
			||||||
                              {'content_type': 1})
 | 
					 | 
				
			||||||
    if not file_doc:
 | 
					 | 
				
			||||||
        log.warning('deduct_content_type: Node %s refers to non-existing file %s, rejecting.',
 | 
					 | 
				
			||||||
                    node_id, file_id)
 | 
					 | 
				
			||||||
        raise wz_exceptions.UnprocessableEntity('File property refers to non-existing file')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Guess the node content type from the file content type
 | 
					 | 
				
			||||||
    file_type = file_doc['content_type']
 | 
					 | 
				
			||||||
    if file_type.startswith('video/'):
 | 
					 | 
				
			||||||
        content_type = 'video'
 | 
					 | 
				
			||||||
    elif file_type.startswith('image/'):
 | 
					 | 
				
			||||||
        content_type = 'image'
 | 
					 | 
				
			||||||
    else:
 | 
					 | 
				
			||||||
        content_type = 'file'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    node_doc['properties']['content_type'] = content_type
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def nodes_deduct_content_type(nodes):
 | 
					 | 
				
			||||||
    for node in nodes:
 | 
					 | 
				
			||||||
        deduct_content_type(node)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def before_returning_node(node):
 | 
					 | 
				
			||||||
    # Run validation process, since GET on nodes entry point is public
 | 
					 | 
				
			||||||
    check_permissions('nodes', node, 'GET', append_allowed_methods=True)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Embed short_link_info if the node has a short_code.
 | 
					 | 
				
			||||||
    short_code = node.get('short_code')
 | 
					 | 
				
			||||||
    if short_code:
 | 
					 | 
				
			||||||
        node['short_link'] = short_link_info(short_code)['short_link']
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def before_returning_nodes(nodes):
 | 
					 | 
				
			||||||
    for node in nodes['_items']:
 | 
					 | 
				
			||||||
        before_returning_node(node)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def node_set_default_picture(node, original=None):
 | 
					 | 
				
			||||||
    """Uses the image of an image asset or colour map of texture node as picture."""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if node.get('picture'):
 | 
					 | 
				
			||||||
        log.debug('Node %s already has a picture, not overriding', node.get('_id'))
 | 
					 | 
				
			||||||
        return
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    node_type = node.get('node_type')
 | 
					 | 
				
			||||||
    props = node.get('properties', {})
 | 
					 | 
				
			||||||
    content = props.get('content_type')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if node_type == 'asset' and content == 'image':
 | 
					 | 
				
			||||||
        image_file_id = props.get('file')
 | 
					 | 
				
			||||||
    elif node_type == 'texture':
 | 
					 | 
				
			||||||
        # Find the colour map, defaulting to the first image map available.
 | 
					 | 
				
			||||||
        image_file_id = None
 | 
					 | 
				
			||||||
        for image in props.get('files', []):
 | 
					 | 
				
			||||||
            if image_file_id is None or image.get('map_type') == 'color':
 | 
					 | 
				
			||||||
                image_file_id = image.get('file')
 | 
					 | 
				
			||||||
    else:
 | 
					 | 
				
			||||||
        log.debug('Not setting default picture on node type %s content type %s',
 | 
					 | 
				
			||||||
                  node_type, content)
 | 
					 | 
				
			||||||
        return
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if image_file_id is None:
 | 
					 | 
				
			||||||
        log.debug('Nothing to set the picture to.')
 | 
					 | 
				
			||||||
        return
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    log.debug('Setting default picture for node %s to %s', node.get('_id'), image_file_id)
 | 
					 | 
				
			||||||
    node['picture'] = image_file_id
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def nodes_set_default_picture(nodes):
 | 
					 | 
				
			||||||
    for node in nodes:
 | 
					 | 
				
			||||||
        node_set_default_picture(node)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def before_deleting_node(node: dict):
 | 
					 | 
				
			||||||
    check_permissions('nodes', node, 'DELETE')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def after_deleting_node(item):
 | 
					 | 
				
			||||||
    from pillar.celery import search_index_tasks as index
 | 
					 | 
				
			||||||
    index.node_delete.delay(str(item['_id']))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
only_for_textures = only_for_node_type_decorator('texture')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@only_for_textures
 | 
					 | 
				
			||||||
def texture_sort_files(node, original=None):
 | 
					 | 
				
			||||||
    """Sort files alphabetically by map type, with colour map first."""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    try:
 | 
					 | 
				
			||||||
        files = node['properties']['files']
 | 
					 | 
				
			||||||
    except KeyError:
 | 
					 | 
				
			||||||
        return
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Sort the map types alphabetically, ensuring 'color' comes first.
 | 
					 | 
				
			||||||
    as_dict = {f['map_type']: f for f in files}
 | 
					 | 
				
			||||||
    types = sorted(as_dict.keys(), key=lambda k: '\0' if k == 'color' else k)
 | 
					 | 
				
			||||||
    node['properties']['files'] = [as_dict[map_type] for map_type in types]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def textures_sort_files(nodes):
 | 
					 | 
				
			||||||
    for node in nodes:
 | 
					 | 
				
			||||||
        texture_sort_files(node)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def parse_markdown(node, original=None):
 | 
					 | 
				
			||||||
    import copy
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    projects_collection = current_app.data.driver.db['projects']
 | 
					 | 
				
			||||||
    project = projects_collection.find_one({'_id': node['project']}, {'node_types': 1})
 | 
					 | 
				
			||||||
    # Query node type directly using the key
 | 
					 | 
				
			||||||
    node_type = next(nt for nt in project['node_types']
 | 
					 | 
				
			||||||
                     if nt['name'] == node['node_type'])
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Create a copy to not overwrite the actual schema.
 | 
					 | 
				
			||||||
    schema = copy.deepcopy(current_app.config['DOMAIN']['nodes']['schema'])
 | 
					 | 
				
			||||||
    schema['properties'] = node_type['dyn_schema']
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def find_markdown_fields(schema, node):
 | 
					 | 
				
			||||||
        """Find and process all makrdown validated fields."""
 | 
					 | 
				
			||||||
        for k, v in schema.items():
 | 
					 | 
				
			||||||
            if not isinstance(v, dict):
 | 
					 | 
				
			||||||
                continue
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            if v.get('validator') == 'markdown':
 | 
					 | 
				
			||||||
                # If there is a match with the validator: markdown pair, assign the sibling
 | 
					 | 
				
			||||||
                # property (following the naming convention _<property>_html)
 | 
					 | 
				
			||||||
                # the processed value.
 | 
					 | 
				
			||||||
                if k in node:
 | 
					 | 
				
			||||||
                    html = pillar.markdown.markdown(node[k])
 | 
					 | 
				
			||||||
                    field_name = pillar.markdown.cache_field_name(k)
 | 
					 | 
				
			||||||
                    node[field_name] = html
 | 
					 | 
				
			||||||
            if isinstance(node, dict) and k in node:
 | 
					 | 
				
			||||||
                find_markdown_fields(v, node[k])
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    find_markdown_fields(schema, node)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return 'ok'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def parse_markdowns(items):
 | 
					 | 
				
			||||||
    for item in items:
 | 
					 | 
				
			||||||
        parse_markdown(item)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def setup_app(app, url_prefix):
 | 
					def setup_app(app, url_prefix):
 | 
				
			||||||
    global _tagged
 | 
					    global _tagged
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -493,26 +195,26 @@ def setup_app(app, url_prefix):
 | 
				
			|||||||
    from . import patch
 | 
					    from . import patch
 | 
				
			||||||
    patch.setup_app(app, url_prefix=url_prefix)
 | 
					    patch.setup_app(app, url_prefix=url_prefix)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    app.on_fetched_item_nodes += before_returning_node
 | 
					    app.on_fetched_item_nodes += hooks.before_returning_node
 | 
				
			||||||
    app.on_fetched_resource_nodes += before_returning_nodes
 | 
					    app.on_fetched_resource_nodes += hooks.before_returning_nodes
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    app.on_replace_nodes += before_replacing_node
 | 
					    app.on_replace_nodes += hooks.before_replacing_node
 | 
				
			||||||
    app.on_replace_nodes += parse_markdown
 | 
					    app.on_replace_nodes += hooks.parse_markdown
 | 
				
			||||||
    app.on_replace_nodes += texture_sort_files
 | 
					    app.on_replace_nodes += hooks.texture_sort_files
 | 
				
			||||||
    app.on_replace_nodes += deduct_content_type
 | 
					    app.on_replace_nodes += hooks.deduct_content_type
 | 
				
			||||||
    app.on_replace_nodes += node_set_default_picture
 | 
					    app.on_replace_nodes += hooks.node_set_default_picture
 | 
				
			||||||
    app.on_replaced_nodes += after_replacing_node
 | 
					    app.on_replaced_nodes += hooks.after_replacing_node
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    app.on_insert_nodes += before_inserting_nodes
 | 
					    app.on_insert_nodes += hooks.before_inserting_nodes
 | 
				
			||||||
    app.on_insert_nodes += parse_markdowns
 | 
					    app.on_insert_nodes += hooks.parse_markdowns
 | 
				
			||||||
    app.on_insert_nodes += nodes_deduct_content_type
 | 
					    app.on_insert_nodes += hooks.nodes_deduct_content_type
 | 
				
			||||||
    app.on_insert_nodes += nodes_set_default_picture
 | 
					    app.on_insert_nodes += hooks.nodes_set_default_picture
 | 
				
			||||||
    app.on_insert_nodes += textures_sort_files
 | 
					    app.on_insert_nodes += hooks.textures_sort_files
 | 
				
			||||||
    app.on_inserted_nodes += after_inserting_nodes
 | 
					    app.on_inserted_nodes += hooks.after_inserting_nodes
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    app.on_update_nodes += texture_sort_files
 | 
					    app.on_update_nodes += hooks.texture_sort_files
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    app.on_delete_item_nodes += before_deleting_node
 | 
					    app.on_delete_item_nodes += hooks.before_deleting_node
 | 
				
			||||||
    app.on_deleted_item_nodes += after_deleting_node
 | 
					    app.on_deleted_item_nodes += hooks.after_deleting_node
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    app.register_api_blueprint(blueprint, url_prefix=url_prefix)
 | 
					    app.register_api_blueprint(blueprint, url_prefix=url_prefix)
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										325
									
								
								pillar/api/nodes/hooks.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										325
									
								
								pillar/api/nodes/hooks.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,325 @@
 | 
				
			|||||||
 | 
					import functools
 | 
				
			||||||
 | 
					import logging
 | 
				
			||||||
 | 
					import urllib.parse
 | 
				
			||||||
 | 
					from bson import ObjectId
 | 
				
			||||||
 | 
					from flask import current_app
 | 
				
			||||||
 | 
					from werkzeug import exceptions as wz_exceptions
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import pillar.markdown
 | 
				
			||||||
 | 
					from pillar.api.activities import activity_subscribe, activity_object_add
 | 
				
			||||||
 | 
					from pillar.api.file_storage_backends.gcs import update_file_name
 | 
				
			||||||
 | 
					from pillar.api.node_types import PILLAR_NAMED_NODE_TYPES
 | 
				
			||||||
 | 
					from pillar.api.utils.authorization import check_permissions
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					log = logging.getLogger(__name__)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def before_returning_node(node):
 | 
				
			||||||
 | 
					    # Run validation process, since GET on nodes entry point is public
 | 
				
			||||||
 | 
					    check_permissions('nodes', node, 'GET', append_allowed_methods=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Embed short_link_info if the node has a short_code.
 | 
				
			||||||
 | 
					    short_code = node.get('short_code')
 | 
				
			||||||
 | 
					    if short_code:
 | 
				
			||||||
 | 
					        node['short_link'] = short_link_info(short_code)['short_link']
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def before_returning_nodes(nodes):
 | 
				
			||||||
 | 
					    for node in nodes['_items']:
 | 
				
			||||||
 | 
					        before_returning_node(node)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def only_for_node_type_decorator(*required_node_type_names):
 | 
				
			||||||
 | 
					    """Returns a decorator that checks its first argument's node type.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    If the node type is not of the required node type, returns None,
 | 
				
			||||||
 | 
					    otherwise calls the wrapped function.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    >>> deco = only_for_node_type_decorator('comment')
 | 
				
			||||||
 | 
					    >>> @deco
 | 
				
			||||||
 | 
					    ... def handle_comment(node): pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    >>> deco = only_for_node_type_decorator('comment', 'post')
 | 
				
			||||||
 | 
					    >>> @deco
 | 
				
			||||||
 | 
					    ... def handle_comment_or_post(node): pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Convert to a set for efficient 'x in required_node_type_names' queries.
 | 
				
			||||||
 | 
					    required_node_type_names = set(required_node_type_names)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def only_for_node_type(wrapped):
 | 
				
			||||||
 | 
					        @functools.wraps(wrapped)
 | 
				
			||||||
 | 
					        def wrapper(node, *args, **kwargs):
 | 
				
			||||||
 | 
					            if node.get('node_type') not in required_node_type_names:
 | 
				
			||||||
 | 
					                return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            return wrapped(node, *args, **kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return wrapper
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    only_for_node_type.__doc__ = "Decorator, immediately returns when " \
 | 
				
			||||||
 | 
					                                 "the first argument is not of type %s." % required_node_type_names
 | 
				
			||||||
 | 
					    return only_for_node_type
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def before_replacing_node(item, original):
 | 
				
			||||||
 | 
					    check_permissions('nodes', original, 'PUT')
 | 
				
			||||||
 | 
					    update_file_name(item)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def after_replacing_node(item, original):
 | 
				
			||||||
 | 
					    """Push an update to the Algolia index when a node item is updated. If the
 | 
				
			||||||
 | 
					    project is private, prevent public indexing.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    from pillar.celery import search_index_tasks as index
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    projects_collection = current_app.data.driver.db['projects']
 | 
				
			||||||
 | 
					    project = projects_collection.find_one({'_id': item['project']})
 | 
				
			||||||
 | 
					    if project.get('is_private', False):
 | 
				
			||||||
 | 
					        # Skip index updating and return
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    status = item['properties'].get('status', 'unpublished')
 | 
				
			||||||
 | 
					    node_id = str(item['_id'])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if status == 'published':
 | 
				
			||||||
 | 
					        index.node_save.delay(node_id)
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        index.node_delete.delay(node_id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def before_inserting_nodes(items):
 | 
				
			||||||
 | 
					    """Before inserting a node in the collection we check if the user is allowed
 | 
				
			||||||
 | 
					    and we append the project id to it.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    from pillar.auth import current_user
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    nodes_collection = current_app.data.driver.db['nodes']
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def find_parent_project(node):
 | 
				
			||||||
 | 
					        """Recursive function that finds the ultimate parent of a node."""
 | 
				
			||||||
 | 
					        if node and 'parent' in node:
 | 
				
			||||||
 | 
					            parent = nodes_collection.find_one({'_id': node['parent']})
 | 
				
			||||||
 | 
					            return find_parent_project(parent)
 | 
				
			||||||
 | 
					        if node:
 | 
				
			||||||
 | 
					            return node
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for item in items:
 | 
				
			||||||
 | 
					        check_permissions('nodes', item, 'POST')
 | 
				
			||||||
 | 
					        if 'parent' in item and 'project' not in item:
 | 
				
			||||||
 | 
					            parent = nodes_collection.find_one({'_id': item['parent']})
 | 
				
			||||||
 | 
					            project = find_parent_project(parent)
 | 
				
			||||||
 | 
					            if project:
 | 
				
			||||||
 | 
					                item['project'] = project['_id']
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Default the 'user' property to the current user.
 | 
				
			||||||
 | 
					        item.setdefault('user', current_user.user_id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def after_inserting_nodes(items):
 | 
				
			||||||
 | 
					    for item in items:
 | 
				
			||||||
 | 
					        # Skip subscriptions for first level items (since the context is not a
 | 
				
			||||||
 | 
					        # node, but a project).
 | 
				
			||||||
 | 
					        # TODO: support should be added for mixed context
 | 
				
			||||||
 | 
					        if 'parent' not in item:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        context_object_id = item['parent']
 | 
				
			||||||
 | 
					        if item['node_type'] == 'comment':
 | 
				
			||||||
 | 
					            nodes_collection = current_app.data.driver.db['nodes']
 | 
				
			||||||
 | 
					            parent = nodes_collection.find_one({'_id': item['parent']})
 | 
				
			||||||
 | 
					            # Always subscribe to the parent node
 | 
				
			||||||
 | 
					            activity_subscribe(item['user'], 'node', item['parent'])
 | 
				
			||||||
 | 
					            if parent['node_type'] == 'comment':
 | 
				
			||||||
 | 
					                # If the parent is a comment, we provide its own parent as
 | 
				
			||||||
 | 
					                # context. We do this in order to point the user to an asset
 | 
				
			||||||
 | 
					                # or group when viewing the notification.
 | 
				
			||||||
 | 
					                verb = 'replied'
 | 
				
			||||||
 | 
					                context_object_id = parent['parent']
 | 
				
			||||||
 | 
					                # Subscribe to the parent of the parent comment (post or group)
 | 
				
			||||||
 | 
					                activity_subscribe(item['user'], 'node', parent['parent'])
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                activity_subscribe(item['user'], 'node', item['_id'])
 | 
				
			||||||
 | 
					                verb = 'commented'
 | 
				
			||||||
 | 
					        elif item['node_type'] in PILLAR_NAMED_NODE_TYPES:
 | 
				
			||||||
 | 
					            verb = 'posted'
 | 
				
			||||||
 | 
					            activity_subscribe(item['user'], 'node', item['_id'])
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            # Don't automatically create activities for non-Pillar node types,
 | 
				
			||||||
 | 
					            # as we don't know what would be a suitable verb (among other things).
 | 
				
			||||||
 | 
					            continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        activity_object_add(
 | 
				
			||||||
 | 
					            item['user'],
 | 
				
			||||||
 | 
					            verb,
 | 
				
			||||||
 | 
					            'node',
 | 
				
			||||||
 | 
					            item['_id'],
 | 
				
			||||||
 | 
					            'node',
 | 
				
			||||||
 | 
					            context_object_id
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def deduct_content_type(node_doc, original=None):
 | 
				
			||||||
 | 
					    """Deduct the content type from the attached file, if any."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if node_doc['node_type'] != 'asset':
 | 
				
			||||||
 | 
					        log.debug('deduct_content_type: called on node type %r, ignoring', node_doc['node_type'])
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    node_id = node_doc.get('_id')
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        file_id = ObjectId(node_doc['properties']['file'])
 | 
				
			||||||
 | 
					    except KeyError:
 | 
				
			||||||
 | 
					        if node_id is None:
 | 
				
			||||||
 | 
					            # Creation of a file-less node is allowed, but updates aren't.
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        log.warning('deduct_content_type: Asset without properties.file, rejecting.')
 | 
				
			||||||
 | 
					        raise wz_exceptions.UnprocessableEntity('Missing file property for asset node')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    files = current_app.data.driver.db['files']
 | 
				
			||||||
 | 
					    file_doc = files.find_one({'_id': file_id},
 | 
				
			||||||
 | 
					                              {'content_type': 1})
 | 
				
			||||||
 | 
					    if not file_doc:
 | 
				
			||||||
 | 
					        log.warning('deduct_content_type: Node %s refers to non-existing file %s, rejecting.',
 | 
				
			||||||
 | 
					                    node_id, file_id)
 | 
				
			||||||
 | 
					        raise wz_exceptions.UnprocessableEntity('File property refers to non-existing file')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Guess the node content type from the file content type
 | 
				
			||||||
 | 
					    file_type = file_doc['content_type']
 | 
				
			||||||
 | 
					    if file_type.startswith('video/'):
 | 
				
			||||||
 | 
					        content_type = 'video'
 | 
				
			||||||
 | 
					    elif file_type.startswith('image/'):
 | 
				
			||||||
 | 
					        content_type = 'image'
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        content_type = 'file'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    node_doc['properties']['content_type'] = content_type
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def nodes_deduct_content_type(nodes):
 | 
				
			||||||
 | 
					    for node in nodes:
 | 
				
			||||||
 | 
					        deduct_content_type(node)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def node_set_default_picture(node, original=None):
 | 
				
			||||||
 | 
					    """Uses the image of an image asset or colour map of texture node as picture."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if node.get('picture'):
 | 
				
			||||||
 | 
					        log.debug('Node %s already has a picture, not overriding', node.get('_id'))
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    node_type = node.get('node_type')
 | 
				
			||||||
 | 
					    props = node.get('properties', {})
 | 
				
			||||||
 | 
					    content = props.get('content_type')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if node_type == 'asset' and content == 'image':
 | 
				
			||||||
 | 
					        image_file_id = props.get('file')
 | 
				
			||||||
 | 
					    elif node_type == 'texture':
 | 
				
			||||||
 | 
					        # Find the colour map, defaulting to the first image map available.
 | 
				
			||||||
 | 
					        image_file_id = None
 | 
				
			||||||
 | 
					        for image in props.get('files', []):
 | 
				
			||||||
 | 
					            if image_file_id is None or image.get('map_type') == 'color':
 | 
				
			||||||
 | 
					                image_file_id = image.get('file')
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        log.debug('Not setting default picture on node type %s content type %s',
 | 
				
			||||||
 | 
					                  node_type, content)
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if image_file_id is None:
 | 
				
			||||||
 | 
					        log.debug('Nothing to set the picture to.')
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    log.debug('Setting default picture for node %s to %s', node.get('_id'), image_file_id)
 | 
				
			||||||
 | 
					    node['picture'] = image_file_id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def nodes_set_default_picture(nodes):
 | 
				
			||||||
 | 
					    for node in nodes:
 | 
				
			||||||
 | 
					        node_set_default_picture(node)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def before_deleting_node(node: dict):
 | 
				
			||||||
 | 
					    check_permissions('nodes', node, 'DELETE')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def after_deleting_node(item):
 | 
				
			||||||
 | 
					    from pillar.celery import search_index_tasks as index
 | 
				
			||||||
 | 
					    index.node_delete.delay(str(item['_id']))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					only_for_textures = only_for_node_type_decorator('texture')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@only_for_textures
 | 
				
			||||||
 | 
					def texture_sort_files(node, original=None):
 | 
				
			||||||
 | 
					    """Sort files alphabetically by map type, with colour map first."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        files = node['properties']['files']
 | 
				
			||||||
 | 
					    except KeyError:
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Sort the map types alphabetically, ensuring 'color' comes first.
 | 
				
			||||||
 | 
					    as_dict = {f['map_type']: f for f in files}
 | 
				
			||||||
 | 
					    types = sorted(as_dict.keys(), key=lambda k: '\0' if k == 'color' else k)
 | 
				
			||||||
 | 
					    node['properties']['files'] = [as_dict[map_type] for map_type in types]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def textures_sort_files(nodes):
 | 
				
			||||||
 | 
					    for node in nodes:
 | 
				
			||||||
 | 
					        texture_sort_files(node)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def parse_markdown(node, original=None):
 | 
				
			||||||
 | 
					    import copy
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    projects_collection = current_app.data.driver.db['projects']
 | 
				
			||||||
 | 
					    project = projects_collection.find_one({'_id': node['project']}, {'node_types': 1})
 | 
				
			||||||
 | 
					    # Query node type directly using the key
 | 
				
			||||||
 | 
					    node_type = next(nt for nt in project['node_types']
 | 
				
			||||||
 | 
					                     if nt['name'] == node['node_type'])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Create a copy to not overwrite the actual schema.
 | 
				
			||||||
 | 
					    schema = copy.deepcopy(current_app.config['DOMAIN']['nodes']['schema'])
 | 
				
			||||||
 | 
					    schema['properties'] = node_type['dyn_schema']
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def find_markdown_fields(schema, node):
 | 
				
			||||||
 | 
					        """Find and process all makrdown validated fields."""
 | 
				
			||||||
 | 
					        for k, v in schema.items():
 | 
				
			||||||
 | 
					            if not isinstance(v, dict):
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if v.get('validator') == 'markdown':
 | 
				
			||||||
 | 
					                # If there is a match with the validator: markdown pair, assign the sibling
 | 
				
			||||||
 | 
					                # property (following the naming convention _<property>_html)
 | 
				
			||||||
 | 
					                # the processed value.
 | 
				
			||||||
 | 
					                if k in node:
 | 
				
			||||||
 | 
					                    html = pillar.markdown.markdown(node[k])
 | 
				
			||||||
 | 
					                    field_name = pillar.markdown.cache_field_name(k)
 | 
				
			||||||
 | 
					                    node[field_name] = html
 | 
				
			||||||
 | 
					            if isinstance(node, dict) and k in node:
 | 
				
			||||||
 | 
					                find_markdown_fields(v, node[k])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    find_markdown_fields(schema, node)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return 'ok'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def parse_markdowns(items):
 | 
				
			||||||
 | 
					    for item in items:
 | 
				
			||||||
 | 
					        parse_markdown(item)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def short_link_info(short_code):
 | 
				
			||||||
 | 
					    """Returns the short link info in a dict."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    short_link = urllib.parse.urljoin(
 | 
				
			||||||
 | 
					        current_app.config['SHORT_LINK_BASE_URL'], short_code)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					        'short_code': short_code,
 | 
				
			||||||
 | 
					        'short_link': short_link,
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
@@ -162,9 +162,12 @@ class YouTube:
 | 
				
			|||||||
        if not youtube_id:
 | 
					        if not youtube_id:
 | 
				
			||||||
            return html_module.escape('{youtube invalid YouTube ID/URL}')
 | 
					            return html_module.escape('{youtube invalid YouTube ID/URL}')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        src = f'https://www.youtube.com/embed/{youtube_id}?rel=0'
 | 
					        src  = f'https://www.youtube.com/embed/{youtube_id}?rel=0'
 | 
				
			||||||
        html = f'<iframe class="shortcode youtube" width="{width}" height="{height}" src="{src}"' \
 | 
					        html = f'<div class="embed-responsive embed-responsive-16by9">' \
 | 
				
			||||||
               f' frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>'
 | 
					               f'<iframe class="shortcode youtube embed-responsive-item"' \
 | 
				
			||||||
 | 
					               f' width="{width}" height="{height}" src="{src}"' \
 | 
				
			||||||
 | 
					               f' frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>' \
 | 
				
			||||||
 | 
					               f'</div>'
 | 
				
			||||||
        return html
 | 
					        return html
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -225,12 +228,25 @@ class Attachment:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        return self.render(file_doc, pargs, kwargs)
 | 
					        return self.render(file_doc, pargs, kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def sdk_file(self, slug: str, node_properties: dict) -> pillarsdk.File:
 | 
					    def sdk_file(self, slug: str, document: dict) -> pillarsdk.File:
 | 
				
			||||||
        """Return the file document for the attachment with this slug."""
 | 
					        """Return the file document for the attachment with this slug."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        from pillar.web import system_util
 | 
					        from pillar.web import system_util
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        attachments = node_properties.get('properties', {}).get('attachments', {})
 | 
					        # TODO (fsiddi) Make explicit what 'document' is.
 | 
				
			||||||
 | 
					        # In some cases we pass the entire node or project documents, in other cases
 | 
				
			||||||
 | 
					        # we pass node.properties. This should be unified at the level of do_markdown.
 | 
				
			||||||
 | 
					        # For now we do a quick hack and first look for 'properties' in the doc,
 | 
				
			||||||
 | 
					        # then we look for 'attachments'.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        doc_properties = document.get('properties')
 | 
				
			||||||
 | 
					        if doc_properties:
 | 
				
			||||||
 | 
					            # We passed an entire document (all nodes must have 'properties')
 | 
				
			||||||
 | 
					            attachments = doc_properties.get('attachments', {})
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            # The value of document could have been defined as 'node.properties'
 | 
				
			||||||
 | 
					            attachments = document.get('attachments', {})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        attachment = attachments.get(slug)
 | 
					        attachment = attachments.get(slug)
 | 
				
			||||||
        if not attachment:
 | 
					        if not attachment:
 | 
				
			||||||
            raise self.NoSuchSlug(slug)
 | 
					            raise self.NoSuchSlug(slug)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -61,16 +61,10 @@ def posts_view(project_id=None, project_url=None, url=None, *, archive=False, pa
 | 
				
			|||||||
        post.picture = get_file(post.picture, api=api)
 | 
					        post.picture = get_file(post.picture, api=api)
 | 
				
			||||||
        post.url = url_for_node(node=post)
 | 
					        post.url = url_for_node(node=post)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # Use the *_main_project.html template for the main blog
 | 
					 | 
				
			||||||
    is_main_project = project_id == current_app.config['MAIN_PROJECT_ID']
 | 
					 | 
				
			||||||
    main_project_template = '_main_project' if is_main_project else ''
 | 
					 | 
				
			||||||
    main_project_template = '_main_project'
 | 
					 | 
				
			||||||
    index_arch = 'archive' if archive else 'index'
 | 
					    index_arch = 'archive' if archive else 'index'
 | 
				
			||||||
    template_path = f'nodes/custom/blog/{index_arch}{main_project_template}.html',
 | 
					    template_path = f'nodes/custom/blog/{index_arch}.html',
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if url:
 | 
					    if url:
 | 
				
			||||||
        template_path = f'nodes/custom/post/view{main_project_template}.html',
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        post = Node.find_one({
 | 
					        post = Node.find_one({
 | 
				
			||||||
            'where': {'parent': blog._id, 'properties.url': url},
 | 
					            'where': {'parent': blog._id, 'properties.url': url},
 | 
				
			||||||
            'embedded': {'node_type': 1, 'user': 1},
 | 
					            'embedded': {'node_type': 1, 'user': 1},
 | 
				
			||||||
@@ -95,6 +89,7 @@ def posts_view(project_id=None, project_url=None, url=None, *, archive=False, pa
 | 
				
			|||||||
    can_create_blog_posts = project.node_type_has_method('post', 'POST', api=api)
 | 
					    can_create_blog_posts = project.node_type_has_method('post', 'POST', api=api)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # Use functools.partial so we can later pass page=X.
 | 
					    # Use functools.partial so we can later pass page=X.
 | 
				
			||||||
 | 
					    is_main_project = project_id == current_app.config['MAIN_PROJECT_ID']
 | 
				
			||||||
    if is_main_project:
 | 
					    if is_main_project:
 | 
				
			||||||
        url_func = functools.partial(url_for, 'main.main_blog_archive')
 | 
					        url_func = functools.partial(url_for, 'main.main_blog_archive')
 | 
				
			||||||
    else:
 | 
					    else:
 | 
				
			||||||
@@ -121,7 +116,7 @@ def posts_view(project_id=None, project_url=None, url=None, *, archive=False, pa
 | 
				
			|||||||
    return render_template(
 | 
					    return render_template(
 | 
				
			||||||
        template_path,
 | 
					        template_path,
 | 
				
			||||||
        blog=blog,
 | 
					        blog=blog,
 | 
				
			||||||
        node=post,
 | 
					        node=post,  # node is used by the generic comments rendering (see custom/_scripts.pug)
 | 
				
			||||||
        posts=posts._items,
 | 
					        posts=posts._items,
 | 
				
			||||||
        posts_meta=pmeta,
 | 
					        posts_meta=pmeta,
 | 
				
			||||||
        more_posts_available=pmeta['total'] > pmeta['max_results'],
 | 
					        more_posts_available=pmeta['total'] > pmeta['max_results'],
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -403,7 +403,6 @@ nav.sidebar
 | 
				
			|||||||
$loader-bar-width: 100px
 | 
					$loader-bar-width: 100px
 | 
				
			||||||
$loader-bar-height: 2px
 | 
					$loader-bar-height: 2px
 | 
				
			||||||
.loader-bar
 | 
					.loader-bar
 | 
				
			||||||
	background-color: $color-background
 | 
					 | 
				
			||||||
	bottom: 0
 | 
						bottom: 0
 | 
				
			||||||
	content: ''
 | 
						content: ''
 | 
				
			||||||
	display: none
 | 
						display: none
 | 
				
			||||||
@@ -412,10 +411,12 @@ $loader-bar-height: 2px
 | 
				
			|||||||
	position: absolute
 | 
						position: absolute
 | 
				
			||||||
	visibility: hidden
 | 
						visibility: hidden
 | 
				
			||||||
	width: 100%
 | 
						width: 100%
 | 
				
			||||||
 | 
						z-index: 20
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	&:before
 | 
						&:before
 | 
				
			||||||
		animation: none
 | 
							animation: none
 | 
				
			||||||
		background-color: $primary
 | 
							background-color: $primary
 | 
				
			||||||
 | 
							background-image: linear-gradient(to right, $primary-accent, $primary)
 | 
				
			||||||
		content: ''
 | 
							content: ''
 | 
				
			||||||
		display: block
 | 
							display: block
 | 
				
			||||||
		height: $loader-bar-height
 | 
							height: $loader-bar-height
 | 
				
			||||||
@@ -453,3 +454,13 @@ $loader-bar-height: 2px
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
.progress-bar
 | 
					.progress-bar
 | 
				
			||||||
	background-color: $primary
 | 
						background-color: $primary
 | 
				
			||||||
 | 
						background-image: linear-gradient(to right, $primary-accent, $primary)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.node-details-description
 | 
				
			||||||
 | 
						+node-details-description
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						@include media-breakpoint-up(lg)
 | 
				
			||||||
 | 
							max-width: map-get($grid-breakpoints, "md")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						@include media-breakpoint-up(xl)
 | 
				
			||||||
 | 
							max-width: map-get($grid-breakpoints, "lg")
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,7 +1,9 @@
 | 
				
			|||||||
$comments-width-max: 710px
 | 
					$comments-width-max: 710px
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.comments-container
 | 
					.comments-container
 | 
				
			||||||
 | 
						max-width: $comments-width-max
 | 
				
			||||||
	position: relative
 | 
						position: relative
 | 
				
			||||||
 | 
						width: 100%
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	#comments-reload
 | 
						#comments-reload
 | 
				
			||||||
		text-align: center
 | 
							text-align: center
 | 
				
			||||||
@@ -314,9 +316,6 @@ $comments-width-max: 710px
 | 
				
			|||||||
					color: $color-success
 | 
										color: $color-success
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.comment-reply
 | 
					.comment-reply
 | 
				
			||||||
	&-container
 | 
					 | 
				
			||||||
		background-color: $color-background
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	/* Little gravatar icon on the left */
 | 
						/* Little gravatar icon on the left */
 | 
				
			||||||
	&-avatar
 | 
						&-avatar
 | 
				
			||||||
		img
 | 
							img
 | 
				
			||||||
@@ -333,7 +332,7 @@ $comments-width-max: 710px
 | 
				
			|||||||
		width: 100%
 | 
							width: 100%
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	&-field
 | 
						&-field
 | 
				
			||||||
		background-color: $color-background-dark
 | 
							background-color: $color-background-light
 | 
				
			||||||
		border-radius: 3px
 | 
							border-radius: 3px
 | 
				
			||||||
		box-shadow: inset 0 0 2px 0 rgba(darken($color-background-dark, 20%), .5)
 | 
							box-shadow: inset 0 0 2px 0 rgba(darken($color-background-dark, 20%), .5)
 | 
				
			||||||
		display: flex
 | 
							display: flex
 | 
				
			||||||
@@ -342,6 +341,7 @@ $comments-width-max: 710px
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		textarea
 | 
							textarea
 | 
				
			||||||
			+node-details-description
 | 
								+node-details-description
 | 
				
			||||||
 | 
								background-color: $color-background-light
 | 
				
			||||||
			border-bottom-right-radius: 0
 | 
								border-bottom-right-radius: 0
 | 
				
			||||||
			border-top-right-radius: 0
 | 
								border-top-right-radius: 0
 | 
				
			||||||
			border: none
 | 
								border: none
 | 
				
			||||||
@@ -376,7 +376,6 @@ $comments-width-max: 710px
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		&.filled
 | 
							&.filled
 | 
				
			||||||
			textarea
 | 
								textarea
 | 
				
			||||||
				background-color: $color-background-light
 | 
					 | 
				
			||||||
				border-bottom: thin solid $color-background
 | 
									border-bottom: thin solid $color-background
 | 
				
			||||||
 | 
					
 | 
				
			||||||
				&:focus
 | 
									&:focus
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -30,6 +30,7 @@ $color-primary: #009eff !default
 | 
				
			|||||||
$color-primary-light: hsl(hue($color-primary), 30%, 90%) !default
 | 
					$color-primary-light: hsl(hue($color-primary), 30%, 90%) !default
 | 
				
			||||||
$color-primary-dark: hsl(hue($color-primary), 80%, 30%) !default
 | 
					$color-primary-dark: hsl(hue($color-primary), 80%, 30%) !default
 | 
				
			||||||
$color-primary-accent:  hsl(hue($color-primary), 100%, 50%) !default
 | 
					$color-primary-accent:  hsl(hue($color-primary), 100%, 50%) !default
 | 
				
			||||||
 | 
					$primary-accent: #0bd
 | 
				
			||||||
 | 
					
 | 
				
			||||||
$color-secondary: #f42942 !default
 | 
					$color-secondary: #f42942 !default
 | 
				
			||||||
$color-secondary-light: hsl(hue($color-secondary), 30%, 90%) !default
 | 
					$color-secondary-light: hsl(hue($color-secondary), 30%, 90%) !default
 | 
				
			||||||
@@ -156,3 +157,7 @@ $tooltip-max-width: auto
 | 
				
			|||||||
$tooltip-opacity: 1
 | 
					$tooltip-opacity: 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
$nav-link-height: 37px
 | 
					$nav-link-height: 37px
 | 
				
			||||||
 | 
					$navbar-padding-x: 0
 | 
				
			||||||
 | 
					$navbar-padding-y: 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					$grid-breakpoints: (xs: 0,sm: 576px,md: 768px,lg: 1100px,xl: 1500px, xxl: 1800px)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -24,13 +24,16 @@
 | 
				
			|||||||
			color: $color-secondary
 | 
								color: $color-secondary
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#notifications-toggle
 | 
					#notifications-toggle
 | 
				
			||||||
 | 
						color: $color-text
 | 
				
			||||||
	cursor: pointer
 | 
						cursor: pointer
 | 
				
			||||||
	font-size: 1.5em
 | 
					 | 
				
			||||||
	position: relative
 | 
						position: relative
 | 
				
			||||||
	user-select: none
 | 
						user-select: none
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	> i:before
 | 
						> i:before
 | 
				
			||||||
		content: '\e815'
 | 
							content: '\e815'
 | 
				
			||||||
 | 
							font-size: 1.3em
 | 
				
			||||||
 | 
							position: relative
 | 
				
			||||||
 | 
							top: 2px
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	&.has-notifications
 | 
						&.has-notifications
 | 
				
			||||||
		> i:before
 | 
							> i:before
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -77,6 +77,8 @@ body.workshops
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
				a
 | 
									a
 | 
				
			||||||
					color: $primary
 | 
										color: $primary
 | 
				
			||||||
 | 
										i
 | 
				
			||||||
 | 
											+active-gradient
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			a
 | 
								a
 | 
				
			||||||
				align-items: center
 | 
									align-items: center
 | 
				
			||||||
@@ -649,9 +651,6 @@ section.node-details-container
 | 
				
			|||||||
			width: 100%
 | 
								width: 100%
 | 
				
			||||||
			max-width: 100%
 | 
								max-width: 100%
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.node-details-description
 | 
					 | 
				
			||||||
	+node-details-description
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.node-details-meta
 | 
					.node-details-meta
 | 
				
			||||||
	> ul
 | 
						> ul
 | 
				
			||||||
		align-items: center
 | 
							align-items: center
 | 
				
			||||||
@@ -1776,7 +1775,7 @@ a.learn-more
 | 
				
			|||||||
	box-shadow: 0 5px 35px rgba(black, .2)
 | 
						box-shadow: 0 5px 35px rgba(black, .2)
 | 
				
			||||||
	color: $color-text-dark-primary
 | 
						color: $color-text-dark-primary
 | 
				
			||||||
	position: absolute
 | 
						position: absolute
 | 
				
			||||||
	top: 0
 | 
						top: -$project_header-height
 | 
				
			||||||
	left: 0
 | 
						left: 0
 | 
				
			||||||
	right: 0
 | 
						right: 0
 | 
				
			||||||
	width: 80%
 | 
						width: 80%
 | 
				
			||||||
@@ -1804,7 +1803,7 @@ a.learn-more
 | 
				
			|||||||
	&.visible
 | 
						&.visible
 | 
				
			||||||
		visibility: visible
 | 
							visibility: visible
 | 
				
			||||||
		opacity: 1
 | 
							opacity: 1
 | 
				
			||||||
		top: $project_header-height
 | 
							top: 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		.overlay-container
 | 
							.overlay-container
 | 
				
			||||||
			.title
 | 
								.title
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -95,7 +95,7 @@ $search-hit-width_grid: 100px
 | 
				
			|||||||
.search-list
 | 
					.search-list
 | 
				
			||||||
	width: 30%
 | 
						width: 30%
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	.card-deck.card-deck-horizontal
 | 
						.card-deck.card-deck-vertical
 | 
				
			||||||
		.card .embed-responsive
 | 
							.card .embed-responsive
 | 
				
			||||||
			max-width: 80px
 | 
								max-width: 80px
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -67,131 +67,6 @@
 | 
				
			|||||||
        &:hover
 | 
					        &:hover
 | 
				
			||||||
          background-color: lighten($provider-color-google, 7%)
 | 
					          background-color: lighten($provider-color-google, 7%)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#settings
 | 
					 | 
				
			||||||
  #settings-sidebar
 | 
					 | 
				
			||||||
    +media-xs
 | 
					 | 
				
			||||||
      width: 100%
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    +container-box
 | 
					 | 
				
			||||||
    background-color: $color-background-light
 | 
					 | 
				
			||||||
    color: $color-text
 | 
					 | 
				
			||||||
    margin-right: 15px
 | 
					 | 
				
			||||||
    width: 30%
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .settings-content
 | 
					 | 
				
			||||||
      padding: 0
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      ul
 | 
					 | 
				
			||||||
        list-style: none
 | 
					 | 
				
			||||||
        margin: 0
 | 
					 | 
				
			||||||
        padding: 0
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      a
 | 
					 | 
				
			||||||
        &:hover
 | 
					 | 
				
			||||||
          text-decoration: none
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          li
 | 
					 | 
				
			||||||
            background-color: lighten($color-background, 5%)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        li
 | 
					 | 
				
			||||||
          border-bottom: thin solid $color-background
 | 
					 | 
				
			||||||
          border-left: thick solid transparent
 | 
					 | 
				
			||||||
          margin: 0
 | 
					 | 
				
			||||||
          padding: 25px
 | 
					 | 
				
			||||||
          transition: all 100ms ease-in-out
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          i
 | 
					 | 
				
			||||||
            font-size: 1.1em
 | 
					 | 
				
			||||||
            padding-right: 15px
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      .active
 | 
					 | 
				
			||||||
        li
 | 
					 | 
				
			||||||
          background-color: lighten($color-background, 5%)
 | 
					 | 
				
			||||||
          border-left: thick solid $color-info
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  #settings-container
 | 
					 | 
				
			||||||
    +media-xs
 | 
					 | 
				
			||||||
      width: 100%
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    +container-box
 | 
					 | 
				
			||||||
    background-color: $color-background-light
 | 
					 | 
				
			||||||
    width: 70%
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  .settings-header
 | 
					 | 
				
			||||||
    background-color: $color-background
 | 
					 | 
				
			||||||
    border-top-left-radius: 3px
 | 
					 | 
				
			||||||
    border-top-right-radius: 3px
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .settings-title
 | 
					 | 
				
			||||||
      font:
 | 
					 | 
				
			||||||
        size: 1.5em
 | 
					 | 
				
			||||||
        weight: 300
 | 
					 | 
				
			||||||
      padding: 10px 15px 10px 25px
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  .settings-content
 | 
					 | 
				
			||||||
    padding: 25px
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .settings-billing-info
 | 
					 | 
				
			||||||
      font-size: 1.2em
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .subscription-active
 | 
					 | 
				
			||||||
      color: $color-success
 | 
					 | 
				
			||||||
      padding-bottom: 20px
 | 
					 | 
				
			||||||
    .subscription-demo
 | 
					 | 
				
			||||||
      color: $color-info
 | 
					 | 
				
			||||||
      margin-top: 0
 | 
					 | 
				
			||||||
    .subscription-missing
 | 
					 | 
				
			||||||
      color: $color-danger
 | 
					 | 
				
			||||||
      margin-top: 0
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  .button-submit
 | 
					 | 
				
			||||||
    clear: both
 | 
					 | 
				
			||||||
    display: block
 | 
					 | 
				
			||||||
    min-width: 200px
 | 
					 | 
				
			||||||
    margin: 0 auto
 | 
					 | 
				
			||||||
    +button($color-primary, 3px, true)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#settings-container
 | 
					 | 
				
			||||||
  #settings-form
 | 
					 | 
				
			||||||
    width: 100%
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  .settings-form
 | 
					 | 
				
			||||||
    align-items: center
 | 
					 | 
				
			||||||
    display: flex
 | 
					 | 
				
			||||||
    justify-content: center
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .left, .right
 | 
					 | 
				
			||||||
      padding: 25px 0
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .left
 | 
					 | 
				
			||||||
      width: 60%
 | 
					 | 
				
			||||||
      float: left
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .right
 | 
					 | 
				
			||||||
      width: 40%
 | 
					 | 
				
			||||||
      float: right
 | 
					 | 
				
			||||||
      text-align: center
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    label
 | 
					 | 
				
			||||||
      color: $color-text
 | 
					 | 
				
			||||||
      display: block
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .settings-avatar
 | 
					 | 
				
			||||||
      img
 | 
					 | 
				
			||||||
        border-radius: 3px
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      span
 | 
					 | 
				
			||||||
        display: block
 | 
					 | 
				
			||||||
        padding: 15px 0
 | 
					 | 
				
			||||||
        font:
 | 
					 | 
				
			||||||
          size: .9em
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    .settings-password
 | 
					 | 
				
			||||||
      color: $color-text-dark-primary
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
#user-edit-container
 | 
					#user-edit-container
 | 
				
			||||||
  padding: 15px
 | 
					  padding: 15px
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -171,17 +171,25 @@
 | 
				
			|||||||
/* Small but wide: phablets, iPads
 | 
					/* Small but wide: phablets, iPads
 | 
				
			||||||
 **  Menu is collapsed, columns stack, no brand */
 | 
					 **  Menu is collapsed, columns stack, no brand */
 | 
				
			||||||
=media-sm
 | 
					=media-sm
 | 
				
			||||||
	@media (min-width: #{$screen-tablet}) and (max-width: #{$screen-desktop - 1px})
 | 
						@include media-breakpoint-up(sm)
 | 
				
			||||||
		@content
 | 
							@content
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* Tablets portrait.
 | 
					/* Tablets portrait.
 | 
				
			||||||
 **  Menu is expanded, but columns stack, brand is shown */
 | 
					 **  Menu is expanded, but columns stack, brand is shown */
 | 
				
			||||||
=media-md
 | 
					=media-md
 | 
				
			||||||
	@media (min-width: #{$screen-desktop})
 | 
						@include media-breakpoint-up(md)
 | 
				
			||||||
		@content
 | 
							@content
 | 
				
			||||||
 | 
					
 | 
				
			||||||
=media-lg
 | 
					=media-lg
 | 
				
			||||||
	@media (min-width: #{$screen-lg-desktop})
 | 
						@include media-breakpoint-up(lg)
 | 
				
			||||||
 | 
							@content
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					=media-xl
 | 
				
			||||||
 | 
						@include media-breakpoint-up(xl)
 | 
				
			||||||
 | 
							@content
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					=media-xxl
 | 
				
			||||||
 | 
						@include media-breakpoint-up(xxl)
 | 
				
			||||||
		@content
 | 
							@content
 | 
				
			||||||
 | 
					
 | 
				
			||||||
=media-print
 | 
					=media-print
 | 
				
			||||||
@@ -659,6 +667,9 @@
 | 
				
			|||||||
.user-select-none
 | 
					.user-select-none
 | 
				
			||||||
	user-select: none
 | 
						user-select: none
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.pointer-events-none
 | 
				
			||||||
 | 
						pointer-events: none
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Bootstrap has .img-fluid, a class to limit the width of an image to 100%.
 | 
					// Bootstrap has .img-fluid, a class to limit the width of an image to 100%.
 | 
				
			||||||
// .imgs-fluid below is to be applied on a parent container when we can't add
 | 
					// .imgs-fluid below is to be applied on a parent container when we can't add
 | 
				
			||||||
// classes to the images themselves. e.g. the blog.
 | 
					// classes to the images themselves. e.g. the blog.
 | 
				
			||||||
@@ -669,3 +680,15 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
.overflow-hidden
 | 
					.overflow-hidden
 | 
				
			||||||
	overflow: hidden
 | 
						overflow: hidden
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					=text-gradient($color_from, $color_to)
 | 
				
			||||||
 | 
							background: linear-gradient(to right, $color_from, $color_to)
 | 
				
			||||||
 | 
							background-clip: text
 | 
				
			||||||
 | 
							-webkit-background-clip: text
 | 
				
			||||||
 | 
							-webkit-text-fill-color: transparent
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					=active-gradient
 | 
				
			||||||
 | 
						+text-gradient($primary-accent, $primary)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						&:before
 | 
				
			||||||
 | 
							+text-gradient($primary-accent, $primary)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -15,85 +15,39 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/code"
 | 
					@import "../../node_modules/bootstrap/scss/code"
 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/grid"
 | 
					@import "../../node_modules/bootstrap/scss/grid"
 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/tables"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/forms"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/buttons"
 | 
					@import "../../node_modules/bootstrap/scss/buttons"
 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/transitions"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/dropdown"
 | 
					@import "../../node_modules/bootstrap/scss/dropdown"
 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/button-group"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/input-group"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/custom-forms"
 | 
					@import "../../node_modules/bootstrap/scss/custom-forms"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/nav"
 | 
					@import "../../node_modules/bootstrap/scss/nav"
 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/navbar"
 | 
					@import "../../node_modules/bootstrap/scss/navbar"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/card"
 | 
					@import "../../node_modules/bootstrap/scss/card"
 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/breadcrumb"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/pagination"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/badge"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/jumbotron"
 | 
					@import "../../node_modules/bootstrap/scss/jumbotron"
 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/alert"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/progress"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/media"
 | 
					@import "../../node_modules/bootstrap/scss/media"
 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/list-group"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/close"
 | 
					@import "../../node_modules/bootstrap/scss/close"
 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/modal"
 | 
					@import "../../node_modules/bootstrap/scss/modal"
 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/tooltip"
 | 
					@import "../../node_modules/bootstrap/scss/tooltip"
 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/popover"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/carousel"
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/utilities"
 | 
					@import "../../node_modules/bootstrap/scss/utilities"
 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/print"
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Pillar components.
 | 
					// Pillar components.
 | 
				
			||||||
@import "apps_base"
 | 
					@import "apps_base"
 | 
				
			||||||
@import "components/base"
 | 
					@import "components/base"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@import "components/card"
 | 
				
			||||||
@import "components/jumbotron"
 | 
					@import "components/jumbotron"
 | 
				
			||||||
@import "components/alerts"
 | 
					 | 
				
			||||||
@import "components/navbar"
 | 
					@import "components/navbar"
 | 
				
			||||||
@import "components/dropdown"
 | 
					@import "components/dropdown"
 | 
				
			||||||
@import "components/footer"
 | 
					@import "components/footer"
 | 
				
			||||||
@import "components/shortcode"
 | 
					@import "components/shortcode"
 | 
				
			||||||
@import "components/statusbar"
 | 
					 | 
				
			||||||
@import "components/search"
 | 
					 | 
				
			||||||
@import "components/flyout"
 | 
					@import "components/flyout"
 | 
				
			||||||
@import "components/forms"
 | 
					 | 
				
			||||||
@import "components/inputs"
 | 
					 | 
				
			||||||
@import "components/buttons"
 | 
					@import "components/buttons"
 | 
				
			||||||
@import "components/popover"
 | 
					 | 
				
			||||||
@import "components/tooltip"
 | 
					@import "components/tooltip"
 | 
				
			||||||
@import "components/checkbox"
 | 
					 | 
				
			||||||
@import "components/overlay"
 | 
					@import "components/overlay"
 | 
				
			||||||
@import "components/card"
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
@import _comments
 | 
					@import _comments
 | 
				
			||||||
@import _error
 | 
					@import _notifications
 | 
				
			||||||
@import _search
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@import components/base
 | 
					 | 
				
			||||||
@import components/alerts
 | 
					 | 
				
			||||||
@import components/navbar
 | 
					 | 
				
			||||||
@import components/footer
 | 
					 | 
				
			||||||
@import components/shortcode
 | 
					 | 
				
			||||||
@import components/statusbar
 | 
					 | 
				
			||||||
@import components/search
 | 
					 | 
				
			||||||
@import components/flyout
 | 
					 | 
				
			||||||
@import components/forms
 | 
					 | 
				
			||||||
@import components/inputs
 | 
					 | 
				
			||||||
@import components/buttons
 | 
					 | 
				
			||||||
@import components/popover
 | 
					 | 
				
			||||||
@import components/tooltip
 | 
					 | 
				
			||||||
@import components/checkbox
 | 
					 | 
				
			||||||
@import components/overlay
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#blog_container
 | 
					 | 
				
			||||||
	+media-xs
 | 
					 | 
				
			||||||
		flex-direction: column
 | 
					 | 
				
			||||||
		padding-top: 0
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	video
 | 
					 | 
				
			||||||
		max-width: 100%
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
#blog_post-edit-form
 | 
					#blog_post-edit-form
 | 
				
			||||||
	padding: 20px
 | 
						padding: 20px
 | 
				
			||||||
@@ -166,7 +120,6 @@
 | 
				
			|||||||
					margin-bottom: 15px
 | 
										margin-bottom: 15px
 | 
				
			||||||
					border-top: thin solid $color-text-dark-hint
 | 
										border-top: thin solid $color-text-dark-hint
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
	.form-group.description,
 | 
						.form-group.description,
 | 
				
			||||||
	.form-group.summary,
 | 
						.form-group.summary,
 | 
				
			||||||
	.form-group.content
 | 
						.form-group.content
 | 
				
			||||||
@@ -234,64 +187,10 @@
 | 
				
			|||||||
					color: transparent
 | 
										color: transparent
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#blog_post-create-container,
 | 
					 | 
				
			||||||
#blog_post-edit-container
 | 
					 | 
				
			||||||
	padding: 25px
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.blog_index-item
 | 
					 | 
				
			||||||
	.item-picture
 | 
					 | 
				
			||||||
		position: relative
 | 
					 | 
				
			||||||
		width: 100%
 | 
					 | 
				
			||||||
		max-height: 350px
 | 
					 | 
				
			||||||
		min-height: 200px
 | 
					 | 
				
			||||||
		height: auto
 | 
					 | 
				
			||||||
		overflow: hidden
 | 
					 | 
				
			||||||
		border-top-left-radius: 3px
 | 
					 | 
				
			||||||
		border-top-right-radius: 3px
 | 
					 | 
				
			||||||
		+clearfix
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		img
 | 
					 | 
				
			||||||
			+position-center-translate
 | 
					 | 
				
			||||||
			width: 100%
 | 
					 | 
				
			||||||
			border-top-left-radius: 3px
 | 
					 | 
				
			||||||
			border-top-right-radius: 3px
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		+media-xs
 | 
					 | 
				
			||||||
			min-height: 150px
 | 
					 | 
				
			||||||
		+media-sm
 | 
					 | 
				
			||||||
			min-height: 150px
 | 
					 | 
				
			||||||
		+media-md
 | 
					 | 
				
			||||||
			min-height: 250px
 | 
					 | 
				
			||||||
		+media-lg
 | 
					 | 
				
			||||||
			min-height: 250px
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	.item-content
 | 
					 | 
				
			||||||
		+node-details-description
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		+media-xs
 | 
					 | 
				
			||||||
			padding:
 | 
					 | 
				
			||||||
				left: 0
 | 
					 | 
				
			||||||
				right: 0
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		img
 | 
					 | 
				
			||||||
			display: block
 | 
					 | 
				
			||||||
			margin: 0 auto
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	.item-meta
 | 
					 | 
				
			||||||
		color: $color-text-dark-secondary
 | 
					 | 
				
			||||||
		padding:
 | 
					 | 
				
			||||||
			left: 25px
 | 
					 | 
				
			||||||
			right: 25px
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		+media-xs
 | 
					 | 
				
			||||||
			padding:
 | 
					 | 
				
			||||||
				left: 10px
 | 
					 | 
				
			||||||
				right: 10px
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#blog_index-container,
 | 
					 | 
				
			||||||
#blog_post-create-container,
 | 
					#blog_post-create-container,
 | 
				
			||||||
#blog_post-edit-container
 | 
					#blog_post-edit-container
 | 
				
			||||||
	+container-box
 | 
						+container-box
 | 
				
			||||||
 | 
						padding: 25px
 | 
				
			||||||
	width: 75%
 | 
						width: 75%
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	+media-xs
 | 
						+media-xs
 | 
				
			||||||
@@ -306,11 +205,6 @@
 | 
				
			|||||||
	+media-lg
 | 
						+media-lg
 | 
				
			||||||
		width: 100%
 | 
							width: 100%
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
	.item-picture+.button-back+.button-edit
 | 
					 | 
				
			||||||
		right: 20px
 | 
					 | 
				
			||||||
		top: 20px
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#blog_post-edit-form
 | 
					#blog_post-edit-form
 | 
				
			||||||
	padding: 0
 | 
						padding: 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -345,206 +239,3 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	.form-upload-file-meta
 | 
						.form-upload-file-meta
 | 
				
			||||||
		width: initial
 | 
							width: initial
 | 
				
			||||||
 | 
					 | 
				
			||||||
#blog_post-edit-title
 | 
					 | 
				
			||||||
	padding: 0
 | 
					 | 
				
			||||||
	color: $color-text
 | 
					 | 
				
			||||||
	font:
 | 
					 | 
				
			||||||
		size: 1.8em
 | 
					 | 
				
			||||||
		weight: 300
 | 
					 | 
				
			||||||
	margin: 0 20px 15px 0
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#blog_index-sidebar
 | 
					 | 
				
			||||||
	width: 25%
 | 
					 | 
				
			||||||
	padding: 0 15px
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	+media-xs
 | 
					 | 
				
			||||||
		width: 100%
 | 
					 | 
				
			||||||
		clear: both
 | 
					 | 
				
			||||||
		display: block
 | 
					 | 
				
			||||||
		margin-top: 25px
 | 
					 | 
				
			||||||
	+media-sm
 | 
					 | 
				
			||||||
		width: 40%
 | 
					 | 
				
			||||||
	+media-md
 | 
					 | 
				
			||||||
		width: 30%
 | 
					 | 
				
			||||||
	+media-lg
 | 
					 | 
				
			||||||
		width: 25%
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	.button-back
 | 
					 | 
				
			||||||
		+button($color-info, 6px, true)
 | 
					 | 
				
			||||||
		display: block
 | 
					 | 
				
			||||||
		width: 100%
 | 
					 | 
				
			||||||
		margin: 15px 0 0 0
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	#blog_post-edit-form
 | 
					 | 
				
			||||||
		.form-group
 | 
					 | 
				
			||||||
			.form-control
 | 
					 | 
				
			||||||
				background-color: white
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	.blog_index-sidebar,
 | 
					 | 
				
			||||||
	.blog_project-sidebar
 | 
					 | 
				
			||||||
		+container-box
 | 
					 | 
				
			||||||
		background-color: lighten($color-background, 5%)
 | 
					 | 
				
			||||||
		padding: 20px
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	.blog_project-card
 | 
					 | 
				
			||||||
		position: relative
 | 
					 | 
				
			||||||
		width: 100%
 | 
					 | 
				
			||||||
		border-radius: 3px
 | 
					 | 
				
			||||||
		overflow: hidden
 | 
					 | 
				
			||||||
		background-color: white
 | 
					 | 
				
			||||||
		color: lighten($color-text, 10%)
 | 
					 | 
				
			||||||
		box-shadow: 0 0 30px rgba(black, .2)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		margin:
 | 
					 | 
				
			||||||
			top: 0
 | 
					 | 
				
			||||||
			bottom: 15px
 | 
					 | 
				
			||||||
			left: auto
 | 
					 | 
				
			||||||
			right: auto
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		a.item-header
 | 
					 | 
				
			||||||
			position: relative
 | 
					 | 
				
			||||||
			width: 100%
 | 
					 | 
				
			||||||
			height: 100px
 | 
					 | 
				
			||||||
			display: block
 | 
					 | 
				
			||||||
			background-size: 100% 100%
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			overflow: hidden
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			.overlay
 | 
					 | 
				
			||||||
				z-index: 1
 | 
					 | 
				
			||||||
				width: 100%
 | 
					 | 
				
			||||||
				height: 100px
 | 
					 | 
				
			||||||
				@include overlay(transparent, 0%, white, 100%)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			img.background
 | 
					 | 
				
			||||||
				width: 100%
 | 
					 | 
				
			||||||
				transform: scale(1.4)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		.card-thumbnail
 | 
					 | 
				
			||||||
			position: absolute
 | 
					 | 
				
			||||||
			z-index: 2
 | 
					 | 
				
			||||||
			height: 90px
 | 
					 | 
				
			||||||
			width: 90px
 | 
					 | 
				
			||||||
			display: block
 | 
					 | 
				
			||||||
			top: 35px
 | 
					 | 
				
			||||||
			left: 50%
 | 
					 | 
				
			||||||
			transform: translateX(-50%)
 | 
					 | 
				
			||||||
			background-color: white
 | 
					 | 
				
			||||||
			border-radius: 3px
 | 
					 | 
				
			||||||
			overflow: hidden
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			&:hover
 | 
					 | 
				
			||||||
				img.thumb
 | 
					 | 
				
			||||||
					opacity: .9
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			img.thumb
 | 
					 | 
				
			||||||
				width: 100%
 | 
					 | 
				
			||||||
				border-radius: 3px
 | 
					 | 
				
			||||||
				transition: opacity 150ms ease-in-out
 | 
					 | 
				
			||||||
				+position-center-translate
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		.item-info
 | 
					 | 
				
			||||||
			padding: 10px 20px
 | 
					 | 
				
			||||||
			background-color: white
 | 
					 | 
				
			||||||
			border-bottom-left-radius: 3px
 | 
					 | 
				
			||||||
			border-bottom-right-radius: 3px
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			a.item-title
 | 
					 | 
				
			||||||
				display: inline-block
 | 
					 | 
				
			||||||
				width: 100%
 | 
					 | 
				
			||||||
				padding: 30px 0 15px 0
 | 
					 | 
				
			||||||
				color: $color-text-dark
 | 
					 | 
				
			||||||
				text-align: center
 | 
					 | 
				
			||||||
				font:
 | 
					 | 
				
			||||||
					size: 1.6em
 | 
					 | 
				
			||||||
					weight: 300
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				transition: color 150ms ease-in-out
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				&:hover
 | 
					 | 
				
			||||||
					text-decoration: none
 | 
					 | 
				
			||||||
					color: $color-primary
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.blog-archive-navigation
 | 
					 | 
				
			||||||
	+media-xs
 | 
					 | 
				
			||||||
		font-size: 1em
 | 
					 | 
				
			||||||
		max-width: initial
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	border-bottom: thin solid $color-background-dark
 | 
					 | 
				
			||||||
	display: flex
 | 
					 | 
				
			||||||
	font:
 | 
					 | 
				
			||||||
		size: 1.2em
 | 
					 | 
				
			||||||
		weight: 300
 | 
					 | 
				
			||||||
	margin: 0 auto
 | 
					 | 
				
			||||||
	max-width: 780px
 | 
					 | 
				
			||||||
	text-align: center
 | 
					 | 
				
			||||||
	+text-overflow-ellipsis
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	&:last-child
 | 
					 | 
				
			||||||
		border: none
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	a, span
 | 
					 | 
				
			||||||
		+media-xs
 | 
					 | 
				
			||||||
			padding: 10px
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		flex: 1
 | 
					 | 
				
			||||||
		padding: 25px 15px
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	span
 | 
					 | 
				
			||||||
		color: $color-text-dark-secondary
 | 
					 | 
				
			||||||
		pointer-events: none
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Specific tweaks for blogs in the context of a project.
 | 
					 | 
				
			||||||
#project_context
 | 
					 | 
				
			||||||
	.blog_index-item
 | 
					 | 
				
			||||||
		+media-xs
 | 
					 | 
				
			||||||
			margin-left: 0
 | 
					 | 
				
			||||||
		padding: 0
 | 
					 | 
				
			||||||
		margin-left: 10px
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		&.list
 | 
					 | 
				
			||||||
			margin-left: 35px !important
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		.item-title,
 | 
					 | 
				
			||||||
		.item-info
 | 
					 | 
				
			||||||
			+media-xs
 | 
					 | 
				
			||||||
				padding-left: 0
 | 
					 | 
				
			||||||
			padding-left: 25px
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	#blog_container
 | 
					 | 
				
			||||||
		.comments-container
 | 
					 | 
				
			||||||
			+media-sm
 | 
					 | 
				
			||||||
				margin-left: 10px
 | 
					 | 
				
			||||||
			margin-left: 30px
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	.blog-archive-navigation
 | 
					 | 
				
			||||||
		margin-left: 35px
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Used on the blog.
 | 
					 | 
				
			||||||
.comments-compact
 | 
					 | 
				
			||||||
	.comments-list
 | 
					 | 
				
			||||||
		border: none
 | 
					 | 
				
			||||||
		padding: 0 0 15px 0
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	.comments-container
 | 
					 | 
				
			||||||
		max-width: 680px
 | 
					 | 
				
			||||||
		margin: 0 auto
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		.comment-reply-container
 | 
					 | 
				
			||||||
			background-color: transparent
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			.comment-reply-field
 | 
					 | 
				
			||||||
				textarea, .comment-reply-meta
 | 
					 | 
				
			||||||
					background-color: $color-background-light
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				&.filled
 | 
					 | 
				
			||||||
					.comment-reply-meta
 | 
					 | 
				
			||||||
						background-color: $color-success
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		.comment-reply-form
 | 
					 | 
				
			||||||
			+media-xs
 | 
					 | 
				
			||||||
				padding:
 | 
					 | 
				
			||||||
					left: 0
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,24 +4,38 @@
 | 
				
			|||||||
		@extend .row
 | 
							@extend .row
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		.card
 | 
							.card
 | 
				
			||||||
			@extend .col-md-3
 | 
								@extend .col-md-4
 | 
				
			||||||
			+media-xs
 | 
					
 | 
				
			||||||
 | 
								+media-sm
 | 
				
			||||||
				flex: 1 0 50%
 | 
									flex: 1 0 50%
 | 
				
			||||||
				max-width: 50%
 | 
									max-width: 50%
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			+media-sm
 | 
								+media-md
 | 
				
			||||||
				flex: 1 0 33%
 | 
									flex: 1 0 33%
 | 
				
			||||||
				max-width: 33%
 | 
									max-width: 33%
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			+media-md
 | 
								+media-lg
 | 
				
			||||||
 | 
									flex: 1 0 33%
 | 
				
			||||||
 | 
									max-width: 33%
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								+media-xl
 | 
				
			||||||
				flex: 1 0 25%
 | 
									flex: 1 0 25%
 | 
				
			||||||
				max-width: 25%
 | 
									max-width: 25%
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			+media-lg
 | 
								+media-xxl
 | 
				
			||||||
				flex: 1 0 20%
 | 
									flex: 1 0 20%
 | 
				
			||||||
				max-width: 20%
 | 
									max-width: 20%
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	&.card-deck-horizontal
 | 
							&.card-3-columns .card
 | 
				
			||||||
 | 
								+media-xl
 | 
				
			||||||
 | 
									flex: 1 0 33%
 | 
				
			||||||
 | 
									max-width: 33%
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								+media-xxl
 | 
				
			||||||
 | 
									flex: 1 0 33%
 | 
				
			||||||
 | 
									max-width: 33%
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						&.card-deck-vertical
 | 
				
			||||||
		@extend .flex-column
 | 
							@extend .flex-column
 | 
				
			||||||
		flex-wrap: initial
 | 
							flex-wrap: initial
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -29,6 +43,7 @@
 | 
				
			|||||||
			@extend .w-100
 | 
								@extend .w-100
 | 
				
			||||||
			@extend .flex-row
 | 
								@extend .flex-row
 | 
				
			||||||
			flex: initial
 | 
								flex: initial
 | 
				
			||||||
 | 
								flex-wrap: wrap
 | 
				
			||||||
			max-width: 100%
 | 
								max-width: 100%
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			.card-img-top
 | 
								.card-img-top
 | 
				
			||||||
@@ -98,6 +113,7 @@
 | 
				
			|||||||
		i
 | 
							i
 | 
				
			||||||
			opacity: .2
 | 
								opacity: .2
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Tiny label for cards. e.g. 'WATCHED' on videos. */
 | 
				
			||||||
.card-label
 | 
					.card-label
 | 
				
			||||||
	background-color: rgba($black, .5)
 | 
						background-color: rgba($black, .5)
 | 
				
			||||||
	border-radius: 3px
 | 
						border-radius: 3px
 | 
				
			||||||
@@ -105,7 +121,7 @@
 | 
				
			|||||||
	display: block
 | 
						display: block
 | 
				
			||||||
	font-size: $font-size-xxs
 | 
						font-size: $font-size-xxs
 | 
				
			||||||
	left: 5px
 | 
						left: 5px
 | 
				
			||||||
	top: -25px
 | 
						top: -27px // enough to be above the progress-bar
 | 
				
			||||||
	position: absolute
 | 
						position: absolute
 | 
				
			||||||
	padding: 1px 5px
 | 
						padding: 1px 5px
 | 
				
			||||||
	z-index: 1
 | 
						z-index: 1
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -24,3 +24,21 @@ ul.dropdown-menu
 | 
				
			|||||||
nav .dropdown:hover
 | 
					nav .dropdown:hover
 | 
				
			||||||
	ul.dropdown-menu
 | 
						ul.dropdown-menu
 | 
				
			||||||
		display: block
 | 
							display: block
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					nav .dropdown.large:hover
 | 
				
			||||||
 | 
						.dropdown-menu
 | 
				
			||||||
 | 
							@extend .d-flex
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.dropdown.large.show
 | 
				
			||||||
 | 
						@extend .d-flex
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						.dropdown-menu.show
 | 
				
			||||||
 | 
							@extend .d-flex
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.dropdown-menu-tab
 | 
				
			||||||
 | 
						display: none
 | 
				
			||||||
 | 
						min-width: 100px
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						&.show // .dropdown-menu-tab.show
 | 
				
			||||||
 | 
							@extend .d-flex
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,26 +1,41 @@
 | 
				
			|||||||
// Mainly overrides bootstrap jumbotron settings
 | 
					// Mainly overrides bootstrap jumbotron settings
 | 
				
			||||||
.jumbotron
 | 
					.jumbotron
 | 
				
			||||||
 | 
						@extend .d-flex
 | 
				
			||||||
 | 
						@extend .mb-0
 | 
				
			||||||
 | 
						@extend .rounded-0
 | 
				
			||||||
	background-size: cover
 | 
						background-size: cover
 | 
				
			||||||
	border-radius: 0
 | 
					 | 
				
			||||||
	margin-bottom: 0
 | 
						margin-bottom: 0
 | 
				
			||||||
	padding-top: 10em
 | 
						padding-top: 10em
 | 
				
			||||||
	padding-bottom: 10em
 | 
						padding-bottom: 10em
 | 
				
			||||||
 | 
						position: relative
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						&:after
 | 
				
			||||||
 | 
							background-color: rgba(black, .5)
 | 
				
			||||||
 | 
							bottom: 0
 | 
				
			||||||
 | 
							content: ''
 | 
				
			||||||
 | 
							display: none
 | 
				
			||||||
 | 
							left: 0
 | 
				
			||||||
 | 
							position: absolute
 | 
				
			||||||
 | 
							right: 0
 | 
				
			||||||
 | 
							top: 0
 | 
				
			||||||
 | 
							visibility: hidden
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Black-transparent gradient from left to right to better read the overlay text.
 | 
						// Black-transparent gradient from left to right to better read the overlay text.
 | 
				
			||||||
	&.jumbotron-overlay
 | 
						&.jumbotron-overlay
 | 
				
			||||||
		position: relative
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		&:after
 | 
					 | 
				
			||||||
			background-image: linear-gradient(45deg, rgba(black, .5) 25%, transparent 50%)
 | 
					 | 
				
			||||||
			bottom: 0
 | 
					 | 
				
			||||||
			content: ''
 | 
					 | 
				
			||||||
			left: 0
 | 
					 | 
				
			||||||
			position: absolute
 | 
					 | 
				
			||||||
			right: 0
 | 
					 | 
				
			||||||
			top: 0
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		*
 | 
							*
 | 
				
			||||||
			z-index: 1
 | 
								z-index: 1
 | 
				
			||||||
 | 
							&:after
 | 
				
			||||||
 | 
								display: block
 | 
				
			||||||
 | 
								visibility: visible
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						&.jumbotron-overlay-gradient
 | 
				
			||||||
 | 
							*
 | 
				
			||||||
 | 
								z-index: 1
 | 
				
			||||||
 | 
							&:after
 | 
				
			||||||
 | 
								background-color: transparent
 | 
				
			||||||
 | 
								background-image: linear-gradient(45deg, rgba(black, .5) 25%, transparent 50%)
 | 
				
			||||||
 | 
								display: block
 | 
				
			||||||
 | 
								visibility: visible
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		h2, p
 | 
							h2, p
 | 
				
			||||||
			text-shadow: 1px 1px rgba(black, .2), 1px 1px 25px rgba(black, .5)
 | 
								text-shadow: 1px 1px rgba(black, .2), 1px 1px 25px rgba(black, .5)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,8 +2,7 @@
 | 
				
			|||||||
.navbar
 | 
					.navbar
 | 
				
			||||||
	box-shadow: inset 0 -2px  $color-background
 | 
						box-shadow: inset 0 -2px  $color-background
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.navbar,
 | 
					.nav
 | 
				
			||||||
nav.sidebar
 | 
					 | 
				
			||||||
	border: none
 | 
						border: none
 | 
				
			||||||
	color: $color-text-dark-secondary
 | 
						color: $color-text-dark-secondary
 | 
				
			||||||
	padding: 0
 | 
						padding: 0
 | 
				
			||||||
@@ -19,29 +18,6 @@ nav.sidebar
 | 
				
			|||||||
				margin: 0
 | 
									margin: 0
 | 
				
			||||||
				width: 100%
 | 
									width: 100%
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	.navbar-item
 | 
					 | 
				
			||||||
		align-items: center
 | 
					 | 
				
			||||||
		display: flex
 | 
					 | 
				
			||||||
		user-select: none
 | 
					 | 
				
			||||||
		color: inherit
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		+media-sm
 | 
					 | 
				
			||||||
			padding-left: 10px
 | 
					 | 
				
			||||||
			padding-right: 10px
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		&:hover, &:focus
 | 
					 | 
				
			||||||
			color: $primary
 | 
					 | 
				
			||||||
			background-color: transparent
 | 
					 | 
				
			||||||
			box-shadow: inset 0 -3px 0 $primary
 | 
					 | 
				
			||||||
			text-decoration: none
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		&:focus
 | 
					 | 
				
			||||||
			box-shadow: inset 0 -3px 0 $primary
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		&.active
 | 
					 | 
				
			||||||
			color: $primary
 | 
					 | 
				
			||||||
			box-shadow: inset 0 -3px 0 $primary
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	li
 | 
						li
 | 
				
			||||||
		user-select: none
 | 
							user-select: none
 | 
				
			||||||
		position: relative
 | 
							position: relative
 | 
				
			||||||
@@ -80,49 +56,72 @@ nav.sidebar
 | 
				
			|||||||
			i
 | 
								i
 | 
				
			||||||
				+position-center-translate
 | 
									+position-center-translate
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	.dropdown
 | 
					.dropdown
 | 
				
			||||||
		min-width: 50px // navbar avatar size
 | 
						.navbar-item
 | 
				
			||||||
 | 
							&:hover
 | 
				
			||||||
 | 
								box-shadow: none // Remove the blue underline usually on navbar, from dropdown items.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		span.fa-stack
 | 
						ul.dropdown-menu
 | 
				
			||||||
			position: absolute
 | 
							li
 | 
				
			||||||
			top: 50%
 | 
								a
 | 
				
			||||||
			left: 50%
 | 
									white-space: nowrap
 | 
				
			||||||
			transform: translate(-50%, -50%)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
		ul.dropdown-menu
 | 
								.subitem // e.g. "Not Sintel? Log out"
 | 
				
			||||||
			li
 | 
									font-size: .8em
 | 
				
			||||||
				a
 | 
									text-transform: initial
 | 
				
			||||||
					white-space: nowrap
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
					&:hover
 | 
								i
 | 
				
			||||||
						box-shadow: none // removes underline
 | 
									width: 30px
 | 
				
			||||||
 | 
					
 | 
				
			||||||
				.subitem // e.g. "Not Sintel? Log out"
 | 
								&.subscription-status
 | 
				
			||||||
					font-size: .8em
 | 
									a, a:hover
 | 
				
			||||||
					text-transform: initial
 | 
										color: $white
 | 
				
			||||||
 | 
					
 | 
				
			||||||
				i
 | 
									&.none
 | 
				
			||||||
					width: 30px
 | 
										background-color: $color-danger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
				&.subscription-status
 | 
									&.subscriber
 | 
				
			||||||
					a, a:hover
 | 
										background-color: $color-success
 | 
				
			||||||
						color: $white
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
					&.none
 | 
									&.demo
 | 
				
			||||||
						background-color: $color-danger
 | 
										background-color: $color-info
 | 
				
			||||||
 | 
					
 | 
				
			||||||
					&.subscriber
 | 
									span.info
 | 
				
			||||||
						background-color: $color-success
 | 
										display: block
 | 
				
			||||||
 | 
					
 | 
				
			||||||
					&.demo
 | 
										span.renew
 | 
				
			||||||
						background-color: $color-info
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
					span.info
 | 
					 | 
				
			||||||
						display: block
 | 
											display: block
 | 
				
			||||||
 | 
											font-size: .9em
 | 
				
			||||||
 | 
					
 | 
				
			||||||
						span.renew
 | 
					
 | 
				
			||||||
							display: block
 | 
					.nav-link
 | 
				
			||||||
							font-size: .9em
 | 
						@extend .d-flex
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.nav-title
 | 
				
			||||||
 | 
						white-space: nowrap
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.navbar-item
 | 
				
			||||||
 | 
						align-items: center
 | 
				
			||||||
 | 
						display: flex
 | 
				
			||||||
 | 
						user-select: none
 | 
				
			||||||
 | 
						color: inherit
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						+media-sm
 | 
				
			||||||
 | 
							padding-left: 10px
 | 
				
			||||||
 | 
							padding-right: 10px
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						&:hover, &:focus
 | 
				
			||||||
 | 
							color: $primary
 | 
				
			||||||
 | 
							background-color: transparent
 | 
				
			||||||
 | 
							box-shadow: inset 0 -3px 0 $primary
 | 
				
			||||||
 | 
							text-decoration: none
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						&:focus
 | 
				
			||||||
 | 
							box-shadow: inset 0 -3px 0 $primary
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						&.active
 | 
				
			||||||
 | 
							color: $primary
 | 
				
			||||||
 | 
							box-shadow: inset 0 -3px 0 $primary
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* Secondary navigation. */
 | 
					/* Secondary navigation. */
 | 
				
			||||||
@@ -132,19 +131,36 @@ $nav-secondary-bar-size: -2px
 | 
				
			|||||||
	box-shadow: inset 0 $nav-secondary-bar-size 0 0 $color-background
 | 
						box-shadow: inset 0 $nav-secondary-bar-size 0 0 $color-background
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	.nav-link
 | 
						.nav-link
 | 
				
			||||||
		box-shadow: inset 0 $nav-secondary-bar-size 0 0 $color-background
 | 
					 | 
				
			||||||
		color: $color-text
 | 
							color: $color-text
 | 
				
			||||||
		cursor: pointer
 | 
							cursor: pointer
 | 
				
			||||||
		transition: box-shadow 150ms ease-in-out
 | 
							transition: color 150ms ease-in-out
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							&:after
 | 
				
			||||||
 | 
								background-color: transparent
 | 
				
			||||||
 | 
								bottom: 0
 | 
				
			||||||
 | 
								content: ''
 | 
				
			||||||
 | 
								height: 2px
 | 
				
			||||||
 | 
								position: absolute
 | 
				
			||||||
 | 
								right: 0
 | 
				
			||||||
 | 
								left: 0
 | 
				
			||||||
 | 
								width: 0
 | 
				
			||||||
 | 
								transition: width 150ms ease-in-out
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	.nav-link:hover,
 | 
						.nav-link:hover,
 | 
				
			||||||
	.nav-link.active,
 | 
						.nav-link.active,
 | 
				
			||||||
	.nav-item.dropdown.show .nav-link
 | 
						.nav-item.dropdown.show > .nav-link
 | 
				
			||||||
		// Blue bar on the bottom.
 | 
							// Blue bar on the bottom.
 | 
				
			||||||
		box-shadow: inset 0 $nav-secondary-bar-size 0 0 $primary
 | 
							&:after
 | 
				
			||||||
 | 
								background-color: $primary-accent
 | 
				
			||||||
 | 
								background-image: linear-gradient(to right, $primary-accent 70%, $primary)
 | 
				
			||||||
 | 
								height: 2px
 | 
				
			||||||
 | 
								width: 100%
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							span
 | 
				
			||||||
 | 
								+active-gradient
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		i
 | 
							i
 | 
				
			||||||
			color: $primary
 | 
								color: $primary-accent
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	&.nav-secondary-vertical
 | 
						&.nav-secondary-vertical
 | 
				
			||||||
		align-items: flex-start
 | 
							align-items: flex-start
 | 
				
			||||||
@@ -156,19 +172,30 @@ $nav-secondary-bar-size: -2px
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		// Blue bar on the side.
 | 
							// Blue bar on the side.
 | 
				
			||||||
		.nav-link
 | 
							.nav-link
 | 
				
			||||||
			box-shadow: inset 0 -1px 0 0 $color-background, inset -1px 0 0 0 $color-background
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			&:hover,
 | 
								&:hover,
 | 
				
			||||||
			&.active
 | 
								&.active
 | 
				
			||||||
				box-shadow: inset 0 -1px 0 0 $color-background, inset ($nav-secondary-bar-size * 1.5) 0 0 0 $primary
 | 
									color: $primary
 | 
				
			||||||
 | 
									@extend .bg-white
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	&.nav-main
 | 
									&:after
 | 
				
			||||||
 | 
										background-image: linear-gradient($primary-accent 70%, $primary)
 | 
				
			||||||
 | 
										height: 100%
 | 
				
			||||||
 | 
										left: initial
 | 
				
			||||||
 | 
										top: 0
 | 
				
			||||||
 | 
										width: 3px
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Big navigation dropdown.
 | 
				
			||||||
 | 
					.nav-main
 | 
				
			||||||
 | 
						.nav-secondary
 | 
				
			||||||
		.nav-link
 | 
							.nav-link
 | 
				
			||||||
			color: $color-text-dark-secondary
 | 
								@extend .pr-5
 | 
				
			||||||
 | 
								box-shadow: none
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			&:hover
 | 
								&.nav-see-more
 | 
				
			||||||
				color: $body-color
 | 
									color: $primary
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									i, span
 | 
				
			||||||
 | 
										+active-gradient
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.navbar-overlay
 | 
					.navbar-overlay
 | 
				
			||||||
	+media-lg
 | 
						+media-lg
 | 
				
			||||||
@@ -188,15 +215,6 @@ $nav-secondary-bar-size: -2px
 | 
				
			|||||||
		background-color: $color-background-nav
 | 
							background-color: $color-background-nav
 | 
				
			||||||
		text-shadow: none
 | 
							text-shadow: none
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.navbar-brand
 | 
					 | 
				
			||||||
	color: inherit
 | 
					 | 
				
			||||||
	padding: 0 0 0 3px
 | 
					 | 
				
			||||||
	position: relative
 | 
					 | 
				
			||||||
	top: -2px
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	&:hover
 | 
					 | 
				
			||||||
		color: $primary
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
nav.navbar
 | 
					nav.navbar
 | 
				
			||||||
	.navbar-collapse
 | 
						.navbar-collapse
 | 
				
			||||||
		> ul > li > .navbar-item
 | 
							> ul > li > .navbar-item
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,82 +0,0 @@
 | 
				
			|||||||
// Bootstrap variables and utilities.
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/functions"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/variables"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/mixins"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@import _config
 | 
					 | 
				
			||||||
@import _utils
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Bootstrap components.
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/root"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/reboot"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/type"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/images"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/code"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/grid"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/tables"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/forms"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/buttons"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/transitions"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/dropdown"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/button-group"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/input-group"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/custom-forms"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/nav"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/navbar"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/card"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/breadcrumb"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/pagination"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/badge"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/jumbotron"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/alert"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/progress"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/media"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/list-group"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/close"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/modal"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/tooltip"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/popover"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/carousel"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/utilities"
 | 
					 | 
				
			||||||
@import "../../node_modules/bootstrap/scss/print"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Pillar components.
 | 
					 | 
				
			||||||
@import "apps_base"
 | 
					 | 
				
			||||||
@import "components/base"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@import "components/jumbotron"
 | 
					 | 
				
			||||||
@import "components/alerts"
 | 
					 | 
				
			||||||
@import "components/navbar"
 | 
					 | 
				
			||||||
@import "components/dropdown"
 | 
					 | 
				
			||||||
@import "components/footer"
 | 
					 | 
				
			||||||
@import "components/shortcode"
 | 
					 | 
				
			||||||
@import "components/statusbar"
 | 
					 | 
				
			||||||
@import "components/search"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@import "components/flyout"
 | 
					 | 
				
			||||||
@import "components/forms"
 | 
					 | 
				
			||||||
@import "components/inputs"
 | 
					 | 
				
			||||||
@import "components/buttons"
 | 
					 | 
				
			||||||
@import "components/popover"
 | 
					 | 
				
			||||||
@import "components/tooltip"
 | 
					 | 
				
			||||||
@import "components/checkbox"
 | 
					 | 
				
			||||||
@import "components/overlay"
 | 
					 | 
				
			||||||
@import "components/card"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@import _notifications
 | 
					 | 
				
			||||||
@import _comments
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@import _project
 | 
					 | 
				
			||||||
@import _project-sharing
 | 
					 | 
				
			||||||
@import _project-dashboard
 | 
					 | 
				
			||||||
@import _error
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@import _search
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@import plugins/_jstree
 | 
					 | 
				
			||||||
@import plugins/_js_select2
 | 
					 | 
				
			||||||
@@ -43,7 +43,6 @@ html(lang="en")
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		| {% block css %}
 | 
							| {% block css %}
 | 
				
			||||||
		link(href="{{ url_for('static_pillar', filename='assets/css/font-pillar.css') }}", rel="stylesheet")
 | 
							link(href="{{ url_for('static_pillar', filename='assets/css/font-pillar.css') }}", rel="stylesheet")
 | 
				
			||||||
		link(href="{{ url_for('static_pillar', filename='assets/css/base.css') }}", rel="stylesheet")
 | 
					 | 
				
			||||||
		| {% if title == 'blog' %}
 | 
							| {% if title == 'blog' %}
 | 
				
			||||||
		link(href="{{ url_for('static_pillar', filename='assets/css/blog.css') }}", rel="stylesheet")
 | 
							link(href="{{ url_for('static_pillar', filename='assets/css/blog.css') }}", rel="stylesheet")
 | 
				
			||||||
		| {% else %}
 | 
							| {% else %}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,10 +1,11 @@
 | 
				
			|||||||
| {% if current_user.is_authenticated %}
 | 
					| {% if current_user.is_authenticated %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
li.nav-notifications
 | 
					li.nav-notifications.nav-item
 | 
				
			||||||
	a.navbar-item#notifications-toggle.px-0(
 | 
						a.nav-link.px-2(
 | 
				
			||||||
	title="Notifications",
 | 
							id="notifications-toggle",
 | 
				
			||||||
	data-toggle="tooltip",
 | 
							title="Notifications",
 | 
				
			||||||
	data-placement="bottom")
 | 
							data-toggle="tooltip",
 | 
				
			||||||
 | 
							data-placement="bottom")
 | 
				
			||||||
		i.pi-notifications-none.nav-notifications-icon
 | 
							i.pi-notifications-none.nav-notifications-icon
 | 
				
			||||||
		span#notifications-count
 | 
							span#notifications-count
 | 
				
			||||||
			span
 | 
								span
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -12,25 +12,6 @@ li.dropdown
 | 
				
			|||||||
	ul.dropdown-menu.dropdown-menu-right
 | 
						ul.dropdown-menu.dropdown-menu-right
 | 
				
			||||||
		| {% if not current_user.has_role('protected') %}
 | 
							| {% if not current_user.has_role('protected') %}
 | 
				
			||||||
		| {% block menu_list %}
 | 
							| {% block menu_list %}
 | 
				
			||||||
		li
 | 
					 | 
				
			||||||
			a.navbar-item.px-2(
 | 
					 | 
				
			||||||
				href="{{ url_for('projects.home_project') }}"
 | 
					 | 
				
			||||||
				title="Home")
 | 
					 | 
				
			||||||
				| #[i.pi-home] Home
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		li
 | 
					 | 
				
			||||||
			a.navbar-item.px-2(
 | 
					 | 
				
			||||||
				href="{{ url_for('projects.index') }}"
 | 
					 | 
				
			||||||
				title="My Projects")
 | 
					 | 
				
			||||||
				| #[i.pi-star] My Projects
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		| {% if current_user.has_organizations() %}
 | 
					 | 
				
			||||||
		li
 | 
					 | 
				
			||||||
			a.navbar-item.px-2(
 | 
					 | 
				
			||||||
				href="{{ url_for('pillar.web.organizations.index') }}"
 | 
					 | 
				
			||||||
				title="My Organizations")
 | 
					 | 
				
			||||||
				| #[i.pi-users] My Organizations
 | 
					 | 
				
			||||||
		| {% endif %}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
		li
 | 
							li
 | 
				
			||||||
			a.navbar-item.px-2(
 | 
								a.navbar-item.px-2(
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,7 +6,7 @@
 | 
				
			|||||||
// #}
 | 
					// #}
 | 
				
			||||||
mixin jumbotron(title, text, image, url)
 | 
					mixin jumbotron(title, text, image, url)
 | 
				
			||||||
	if url
 | 
						if url
 | 
				
			||||||
		a.jumbotron.jumbotron-overlay.text-white(
 | 
							a.jumbotron.text-white(
 | 
				
			||||||
			style='background-image: url(' + image + ');',
 | 
								style='background-image: url(' + image + ');',
 | 
				
			||||||
			href=url)&attributes(attributes)
 | 
								href=url)&attributes(attributes)
 | 
				
			||||||
			.container
 | 
								.container
 | 
				
			||||||
@@ -19,7 +19,7 @@ mixin jumbotron(title, text, image, url)
 | 
				
			|||||||
							.lead
 | 
												.lead
 | 
				
			||||||
								=text
 | 
													=text
 | 
				
			||||||
	else
 | 
						else
 | 
				
			||||||
		.jumbotron.jumbotron-overlay.text-white(style='background-image: url(' + image + ');')&attributes(attributes)
 | 
							.jumbotron.text-white(style='background-image: url(' + image + ');')&attributes(attributes)
 | 
				
			||||||
			.container
 | 
								.container
 | 
				
			||||||
				.row
 | 
									.row
 | 
				
			||||||
					.col-md-9
 | 
										.col-md-9
 | 
				
			||||||
@@ -35,8 +35,8 @@ mixin jumbotron(title, text, image, url)
 | 
				
			|||||||
mixin nav-secondary(title)
 | 
					mixin nav-secondary(title)
 | 
				
			||||||
	ul.nav.nav-secondary&attributes(attributes)
 | 
						ul.nav.nav-secondary&attributes(attributes)
 | 
				
			||||||
		if title
 | 
							if title
 | 
				
			||||||
			li.font-weight-bold.px-2
 | 
								li.nav-item
 | 
				
			||||||
				=title
 | 
									span.nav-title.nav-link.font-weight-bold.pointer-events-none= title
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		if block
 | 
							if block
 | 
				
			||||||
			block
 | 
								block
 | 
				
			||||||
@@ -48,8 +48,8 @@ mixin nav-secondary-link()
 | 
				
			|||||||
		a.nav-link&attributes(attributes)
 | 
							a.nav-link&attributes(attributes)
 | 
				
			||||||
			block
 | 
								block
 | 
				
			||||||
 | 
					
 | 
				
			||||||
mixin card-deck()
 | 
					mixin card-deck(max_columns)
 | 
				
			||||||
	.card-deck.card-padless.card-deck-responsive()&attributes(attributes)
 | 
						.card-deck.card-padless.card-deck-responsive(class="card-" + max_columns + "-columns")&attributes(attributes)
 | 
				
			||||||
		if block
 | 
							if block
 | 
				
			||||||
			block
 | 
								block
 | 
				
			||||||
		else
 | 
							else
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,8 +1,11 @@
 | 
				
			|||||||
| {% extends 'nodes/custom/blog/index.html' %}
 | 
					| {% extends 'nodes/custom/blog/index.html' %}
 | 
				
			||||||
| {% import 'nodes/custom/blog/_macros.html' as blogmacros %}
 | 
					| {% import 'nodes/custom/blog/_macros.html' as blogmacros %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| {% block project_context %}
 | 
					| {% block body %}
 | 
				
			||||||
#blog_container
 | 
					.container
 | 
				
			||||||
	#blog_index-container.expand-image-links
 | 
						.pt-5.pb-2
 | 
				
			||||||
		| {{ blogmacros.render_archive(project, posts, posts_meta) }}
 | 
							h2.text-uppercase.font-weight-bold.text-center
 | 
				
			||||||
| {% endblock project_context%}
 | 
								| {{ project.name }} Blog Archive
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						| {{ blogmacros.render_archive(project, posts, posts_meta) }}
 | 
				
			||||||
 | 
					| {% endblock body %}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,9 +0,0 @@
 | 
				
			|||||||
| {% extends 'nodes/custom/blog/index_main_project.html' %}
 | 
					 | 
				
			||||||
| {% import 'nodes/custom/blog/_macros.html' as blogmacros %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
| {% block body %}
 | 
					 | 
				
			||||||
.container
 | 
					 | 
				
			||||||
	h3 Blog Archive
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	| {{ blogmacros.render_archive(project, posts, posts_meta) }}
 | 
					 | 
				
			||||||
| {% endblock body %}
 | 
					 | 
				
			||||||
@@ -1,55 +1,40 @@
 | 
				
			|||||||
| {% extends 'projects/view.html' %}
 | 
					| {% extends 'layout.html' %}
 | 
				
			||||||
| {% import 'nodes/custom/blog/_macros.html' as blogmacros %}
 | 
					| {% import 'nodes/custom/blog/_macros.html' as blogmacros %}
 | 
				
			||||||
 | 
					| {% from 'projects/_macros.html' import render_secondary_navigation %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| {% set title = 'blog' %}
 | 
					| {% set title = 'blog' %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| {% block page_title %}Blog{% endblock%}
 | 
					| {% block page_title %}Blog{% endblock%}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| {% block css %}
 | 
					| {% block navigation_tabs %}
 | 
				
			||||||
| {{ super() }}
 | 
					| {{ render_secondary_navigation(project, navigation_links, title) }}
 | 
				
			||||||
link(href="{{ url_for('static_pillar', filename='assets/css/blog.css') }}", rel="stylesheet")
 | 
					| {% endblock navigation_tabs %}
 | 
				
			||||||
| {% endblock %}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
| {% block project_context %}
 | 
					| {% block body %}
 | 
				
			||||||
| {{ blogmacros.render_blog_index(project, posts, can_create_blog_posts, api, more_posts_available, posts_meta) }}
 | 
					| {{ blogmacros.render_blog_index(node, project, posts, can_create_blog_posts, api, more_posts_available, posts_meta, pages=pages) }}
 | 
				
			||||||
| {% endblock %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
| {% block project_tree %}
 | 
					 | 
				
			||||||
#project_tree.jstree.jstree-default.blog
 | 
					 | 
				
			||||||
	ul.jstree-container-ul.jstree-children
 | 
					 | 
				
			||||||
		li.jstree-node(data-node-type="page")
 | 
					 | 
				
			||||||
			a.jstree-anchor(
 | 
					 | 
				
			||||||
				href="{{ url_for('projects.view', project_url=project.url) }}")
 | 
					 | 
				
			||||||
				| Browse Project
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		li.jstree-node(data-node-type="page")
 | 
					 | 
				
			||||||
			a.jstree-anchor.jstree-clicked(
 | 
					 | 
				
			||||||
				href="{{ url_for('main.project_blog', project_url=project.url) }}") Blog
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		| {% for post in posts %}
 | 
					 | 
				
			||||||
		li.jstree-node
 | 
					 | 
				
			||||||
			a.jstree-anchor.tree-item.post(
 | 
					 | 
				
			||||||
				href="{{ node.url }}")
 | 
					 | 
				
			||||||
				.tree-item-thumbnail
 | 
					 | 
				
			||||||
					| {% if post.picture %}
 | 
					 | 
				
			||||||
					img(src="{{ post.picture.thumbnail('s', api=api) }}")
 | 
					 | 
				
			||||||
					| {% else %}
 | 
					 | 
				
			||||||
					i.pi-document-text
 | 
					 | 
				
			||||||
					| {% endif %}
 | 
					 | 
				
			||||||
				span.tree-item-title {{ post.name }}
 | 
					 | 
				
			||||||
				span.tree-item-info {{ post._created | pretty_date }}
 | 
					 | 
				
			||||||
		| {% endfor %}
 | 
					 | 
				
			||||||
| {% endblock %}
 | 
					| {% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| {% block footer_scripts %}
 | 
					| {% block footer_scripts %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
include ../_scripts
 | 
					include ../_scripts
 | 
				
			||||||
script.
 | 
					script.
 | 
				
			||||||
	/* UI Stuff */
 | 
						hopToTop(); // Display jump to top button
 | 
				
			||||||
	var project_container = document.getElementById('project-container');
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	$(window).on("load resize",function(){
 | 
						/* Expand images when their link points to a jpg/png/gif */
 | 
				
			||||||
		containerResizeY($(window).height());
 | 
						/* TODO: De-duplicate code from view post */
 | 
				
			||||||
 | 
						var page_overlay = document.getElementById('page-overlay');
 | 
				
			||||||
 | 
						$('.item-content a img').on('click', function(e){
 | 
				
			||||||
 | 
							e.preventDefault();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		if ($(window).width() > 480) {
 | 
							var href = $(this).parent().attr('href');
 | 
				
			||||||
			project_container.style.height = (window.innerHeight - project_container.offsetTop) + "px";
 | 
							var src = $(this).attr('src');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							if (href.match("jpg$") || href.match("png$") || href.match("gif$")) {
 | 
				
			||||||
 | 
								$(page_overlay)
 | 
				
			||||||
 | 
											.addClass('active')
 | 
				
			||||||
 | 
											.html('<img src="' + src + '"/>');
 | 
				
			||||||
 | 
							} else {
 | 
				
			||||||
 | 
								window.location.href = href;
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,40 +0,0 @@
 | 
				
			|||||||
| {% extends 'layout.html' %}
 | 
					 | 
				
			||||||
| {% import 'nodes/custom/blog/_macros.html' as blogmacros %}
 | 
					 | 
				
			||||||
| {% set title = 'blog' %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
| {% block page_title %}Blog{% endblock%}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
| {% block css %}
 | 
					 | 
				
			||||||
| {{ super() }}
 | 
					 | 
				
			||||||
link(href="{{ url_for('static_cloud', filename='assets/css/project-landing.css') }}", rel="stylesheet")
 | 
					 | 
				
			||||||
| {% endblock css %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
| {% block body %}
 | 
					 | 
				
			||||||
| {{ blogmacros.render_blog_index(project, posts, can_create_blog_posts, api, more_posts_available, posts_meta, pages=pages) }}
 | 
					 | 
				
			||||||
| {% endblock %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
| {% block footer_scripts %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
include ../_scripts
 | 
					 | 
				
			||||||
script.
 | 
					 | 
				
			||||||
	hopToTop(); // Display jump to top button
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	/* Expand images when their link points to a jpg/png/gif */
 | 
					 | 
				
			||||||
	/* TODO: De-duplicate code from view post */
 | 
					 | 
				
			||||||
	var page_overlay = document.getElementById('page-overlay');
 | 
					 | 
				
			||||||
	$('.item-content a img').on('click', function(e){
 | 
					 | 
				
			||||||
		e.preventDefault();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		var href = $(this).parent().attr('href');
 | 
					 | 
				
			||||||
		var src = $(this).attr('src');
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		if (href.match("jpg$") || href.match("png$") || href.match("gif$")) {
 | 
					 | 
				
			||||||
			$(page_overlay)
 | 
					 | 
				
			||||||
						.addClass('active')
 | 
					 | 
				
			||||||
						.html('<img src="' + src + '"/>');
 | 
					 | 
				
			||||||
		} else {
 | 
					 | 
				
			||||||
			window.location.href = href;
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
| {% endblock %}
 | 
					 | 
				
			||||||
@@ -5,7 +5,7 @@
 | 
				
			|||||||
	class="{% if is_reply %}is-reply{% else %}is-first{% endif %}")
 | 
						class="{% if is_reply %}is-reply{% else %}is-first{% endif %}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	.comment-avatar
 | 
						.comment-avatar
 | 
				
			||||||
		img(src="{{ comment._user.email | gravatar }}")
 | 
							img(src="{{ comment._user.email | gravatar }}", alt="{{ comment._user.full_name }}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	.comment-content
 | 
						.comment-content
 | 
				
			||||||
		.comment-body
 | 
							.comment-body
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -23,60 +23,8 @@ include ../../../mixins/components
 | 
				
			|||||||
				i.pi-list
 | 
									i.pi-list
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		+card-deck(class="px-2")
 | 
							+card-deck(class="px-2")
 | 
				
			||||||
			| 	{% for child in children %}
 | 
								| {% for child in children %}
 | 
				
			||||||
			| {# Browse type: List #}
 | 
					 | 
				
			||||||
			a(
 | 
					 | 
				
			||||||
				href="{{ url_for_node(node=child) }}",
 | 
					 | 
				
			||||||
				data-node_id="{{ child._id }}",
 | 
					 | 
				
			||||||
				class="js-item-open list-node-children-item browse-list")
 | 
					 | 
				
			||||||
				.list-node-children-item-thumbnail
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
					| {% if child.picture %}
 | 
					 | 
				
			||||||
					img(
 | 
					 | 
				
			||||||
						src="{{ child.picture.thumbnail('t', api=api)}} ")
 | 
					 | 
				
			||||||
					| {% else %}
 | 
					 | 
				
			||||||
					.cloud-logo
 | 
					 | 
				
			||||||
						i.pi-blender-cloud
 | 
					 | 
				
			||||||
					| {% endif %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
					| {% if child.permissions.world %}
 | 
					 | 
				
			||||||
					.list-node-children-item-ribbon
 | 
					 | 
				
			||||||
						span free
 | 
					 | 
				
			||||||
					| {% endif %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
					.list-node-children-item-thumbnail-icon
 | 
					 | 
				
			||||||
						| {% if child.properties.content_type and child.properties.content_type == 'video' %}
 | 
					 | 
				
			||||||
						i.pi-play
 | 
					 | 
				
			||||||
						| {% elif child.properties.content_type and child.properties.content_type == 'image' %}
 | 
					 | 
				
			||||||
						i.pi-image
 | 
					 | 
				
			||||||
						| {% elif child.properties.content_type and child.properties.content_type == 'file' %}
 | 
					 | 
				
			||||||
						i.pi-file-archive
 | 
					 | 
				
			||||||
						| {% else %}
 | 
					 | 
				
			||||||
						i.pi-folder
 | 
					 | 
				
			||||||
						| {% endif %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				.list-node-children-item-name {{ child.name }}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				.list-node-children-item-meta
 | 
					 | 
				
			||||||
					| {% if child.properties.status != 'published' %}
 | 
					 | 
				
			||||||
					span.status {{ child.properties.status }}
 | 
					 | 
				
			||||||
					| {% endif %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
					span.type
 | 
					 | 
				
			||||||
						| {% if child.properties.content_type %}
 | 
					 | 
				
			||||||
						| {{ child.properties.content_type | undertitle }} ·
 | 
					 | 
				
			||||||
						| {% elif child.node_type == 'group' %}
 | 
					 | 
				
			||||||
						| Folder ·
 | 
					 | 
				
			||||||
						| {% else %}
 | 
					 | 
				
			||||||
						| {{ child.node_type | undertitle }} ·
 | 
					 | 
				
			||||||
						| {% endif %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
					span(title="Created on {{ child._created }}") {{ child._created | pretty_date }}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			| {# Browse type: Icon #}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			| {{ asset_list_item(child, current_user) }}
 | 
								| {{ asset_list_item(child, current_user) }}
 | 
				
			||||||
 | 
					 | 
				
			||||||
			| {% endfor %}
 | 
								| {% endfor %}
 | 
				
			||||||
			| {% else %}
 | 
								| {% else %}
 | 
				
			||||||
			.list-node-children-container
 | 
								.list-node-children-container
 | 
				
			||||||
@@ -115,14 +63,12 @@ include ../../../mixins/components
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		// Browse type: icon or list
 | 
							// Browse type: icon or list
 | 
				
			||||||
		function projectBrowseTypeIcon() {
 | 
							function projectBrowseTypeIcon() {
 | 
				
			||||||
			$(".list-node-children-item.browse-list").hide();
 | 
								$(".card-deck").removeClass('card-deck-vertical');
 | 
				
			||||||
			$(".list-node-children-item.browse-icon").show();
 | 
					 | 
				
			||||||
			$(".js-btn-browsetoggle").html('<i class="pi-list"></i> List View');
 | 
								$(".js-btn-browsetoggle").html('<i class="pi-list"></i> List View');
 | 
				
			||||||
		};
 | 
							};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		function projectBrowseTypeList() {
 | 
							function projectBrowseTypeList() {
 | 
				
			||||||
			$(".list-node-children-item.browse-list").show();
 | 
								$(".card-deck").addClass('card-deck-vertical');
 | 
				
			||||||
			$(".list-node-children-item.browse-icon").hide();
 | 
					 | 
				
			||||||
			$(".js-btn-browsetoggle").html('<i class="pi-layout"></i> Grid View');
 | 
								$(".js-btn-browsetoggle").html('<i class="pi-layout"></i> Grid View');
 | 
				
			||||||
		};
 | 
							};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,28 +1,28 @@
 | 
				
			|||||||
| {% extends 'projects/landing.html' %}
 | 
					| {% extends 'projects/landing.html' %}
 | 
				
			||||||
 | 
					include ../../../mixins/components
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| {% block body %}
 | 
					| {% block body %}
 | 
				
			||||||
| {% if node.picture %}
 | 
					.expand-image-links.imgs-fluid
 | 
				
			||||||
header
 | 
						| {% if node.picture %}
 | 
				
			||||||
	img.header(src="{{ node.picture.thumbnail('h', api=api) }}")
 | 
						+jumbotron(
 | 
				
			||||||
| {% endif  %}
 | 
							"{{ node.name }}",
 | 
				
			||||||
| {% block navbar_secondary %}
 | 
							"{{ node._created | pretty_date }}{% if node.user.full_name %} · {{ node.user.full_name }}{% endif %}",
 | 
				
			||||||
| {{ super() }}
 | 
							"{{ node.picture.thumbnail('h', api=api) }}",
 | 
				
			||||||
| {% endblock navbar_secondary %}
 | 
							"{{ node.url }}")
 | 
				
			||||||
#node-container
 | 
						| {% endif %}
 | 
				
			||||||
	#node-overlay
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	.node-details-container.page.expand-image-links.imgs-fluid
 | 
					.container.pb-5
 | 
				
			||||||
 | 
						.row
 | 
				
			||||||
 | 
							.col-8.mx-auto
 | 
				
			||||||
 | 
								h2.pt-5.pb-3.text-center {{node.name}}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		h2.pt-3.text-center {{node.name}}
 | 
								| {% if node.description %}
 | 
				
			||||||
 | 
								.node-details-description
 | 
				
			||||||
 | 
									| {{ node | markdowned('description') }}
 | 
				
			||||||
 | 
								| {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		hr
 | 
								small.text-muted
 | 
				
			||||||
 | 
									span(title="created {{ node._created | pretty_date }}") Updated {{ node._updated | pretty_date }}
 | 
				
			||||||
		| {% if node.description %}
 | 
					 | 
				
			||||||
		| {{ node | markdowned('description') }}
 | 
					 | 
				
			||||||
		| {% endif %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		small.text-muted
 | 
					 | 
				
			||||||
			span(title="created {{ node._created | pretty_date }}") Updated {{ node._updated | pretty_date }}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
include ../_scripts
 | 
					include ../_scripts
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -122,41 +122,38 @@ script(type="text/javascript").
 | 
				
			|||||||
					.toLowerCase();
 | 
										.toLowerCase();
 | 
				
			||||||
	};
 | 
						};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	var convert = new Markdown.getSanitizingConverter().makeHtml;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	/* Build the markdown preview when typing in textarea */
 | 
						/* Build the markdown preview when typing in textarea */
 | 
				
			||||||
	$(function() {
 | 
						$(function() {
 | 
				
			||||||
 | 
							var $contentField = $('.form-group.description textarea'),
 | 
				
			||||||
 | 
									$contentPreview = $('<div class="node-edit-form-md-preview" />').insertAfter($contentField);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		var $textarea = $('.form-group.content textarea'),
 | 
							function parseDescriptionContent(content) {
 | 
				
			||||||
				$loader = $('<div class="md-preview-loading"><i class="pi-spin spin"></i></div>').insertAfter($textarea),
 | 
					 | 
				
			||||||
				$preview = $('<div class="node-edit-form-md-preview" />').insertAfter($loader);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
		$loader.hide();
 | 
								$.ajax({
 | 
				
			||||||
 | 
									url: "{{ url_for('nodes.preview_markdown')}}",
 | 
				
			||||||
 | 
									type: 'post',
 | 
				
			||||||
 | 
									data: {content: content},
 | 
				
			||||||
 | 
									headers: {"X-CSRFToken": csrf_token},
 | 
				
			||||||
 | 
									headers: {},
 | 
				
			||||||
 | 
									dataType: 'json'
 | 
				
			||||||
 | 
								})
 | 
				
			||||||
 | 
								.done(function (data) {
 | 
				
			||||||
 | 
									$contentPreview.html(data.content);
 | 
				
			||||||
 | 
								})
 | 
				
			||||||
 | 
								.fail(function (err) {
 | 
				
			||||||
 | 
									toastr.error(xhrErrorResponseMessage(err), 'Parsing failed');
 | 
				
			||||||
 | 
								});
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Delay function to not start converting heavy posts immediately
 | 
							var options = {
 | 
				
			||||||
		var delay = (function(){
 | 
								callback: parseDescriptionContent,
 | 
				
			||||||
			var timer = 0;
 | 
								wait: 750,
 | 
				
			||||||
			return function(callback, ms){
 | 
								highlight: false,
 | 
				
			||||||
				clearTimeout (timer);
 | 
								allowSubmit: false,
 | 
				
			||||||
				timer = setTimeout(callback, ms);
 | 
								captureLength: 2
 | 
				
			||||||
			};
 | 
							}
 | 
				
			||||||
		})();
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
		$textarea.keyup(function() {
 | 
							$contentField.typeWatch(options);
 | 
				
			||||||
			/* If there's an iframe (YouTube embed), delay markdown convert 1.5s */
 | 
					 | 
				
			||||||
			if (/iframe/i.test($textarea.val())) {
 | 
					 | 
				
			||||||
				$loader.show();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
				delay(function(){
 | 
					 | 
				
			||||||
					// Convert markdown
 | 
					 | 
				
			||||||
					$preview.html(convert($textarea.val()));
 | 
					 | 
				
			||||||
					$loader.hide();
 | 
					 | 
				
			||||||
				}, 1500 );
 | 
					 | 
				
			||||||
			} else {
 | 
					 | 
				
			||||||
				// Convert markdown
 | 
					 | 
				
			||||||
				$preview.html(convert($textarea.val()));
 | 
					 | 
				
			||||||
			};
 | 
					 | 
				
			||||||
		}).trigger('keyup');
 | 
					 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	$(function() {
 | 
						$(function() {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,73 +0,0 @@
 | 
				
			|||||||
| {% extends 'projects/view.html' %}
 | 
					 | 
				
			||||||
| {% set title = 'blog' %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
| {% block og %}
 | 
					 | 
				
			||||||
meta(property="og:title", content="{{ node.name }}")
 | 
					 | 
				
			||||||
meta(property="og:url", content="{{ url_for('main.project_blog', project_url=project.url, url=node.properties.url, _external=True)}}")
 | 
					 | 
				
			||||||
meta(property="og:type", content="website")
 | 
					 | 
				
			||||||
| {% if node.picture %}
 | 
					 | 
				
			||||||
meta(property="og:image", content="{{ node.picture.thumbnail('l', api=api) }}")
 | 
					 | 
				
			||||||
| {% endif %}
 | 
					 | 
				
			||||||
meta(property="og:description", content="Blender Cloud is a web based service developed by Blender Institute that allows people to access the training videos and all the data from the open projects.")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
meta(name="twitter:title", content="{{ node.name }}")
 | 
					 | 
				
			||||||
meta(name="twitter:description", content="Blender Cloud is a web based service developed by Blender Institute that allows people to access the training videos and all the data from the open projects.")
 | 
					 | 
				
			||||||
| {% if node.picture %}
 | 
					 | 
				
			||||||
meta(property="og:image", content="{{ node.picture.thumbnail('l', api=api) }}")
 | 
					 | 
				
			||||||
| {% endif %}
 | 
					 | 
				
			||||||
| {% endblock %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
| {% block page_title %}{{node.name}} - Blog{% endblock%}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
| {% block css %}
 | 
					 | 
				
			||||||
| {{ super() }}
 | 
					 | 
				
			||||||
link(href="{{ url_for('static_pillar', filename='assets/css/blog.css') }}", rel="stylesheet")
 | 
					 | 
				
			||||||
| {% endblock %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
| {% block project_context %}
 | 
					 | 
				
			||||||
| {% include 'nodes/custom/post/view_embed.html' %}
 | 
					 | 
				
			||||||
| {% endblock %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
| {% block project_tree %}
 | 
					 | 
				
			||||||
#project_tree.jstree.jstree-default.blog
 | 
					 | 
				
			||||||
	ul.jstree-container-ul.jstree-children
 | 
					 | 
				
			||||||
		li.jstree-node(data-node-type="page")
 | 
					 | 
				
			||||||
			a.jstree-anchor(
 | 
					 | 
				
			||||||
			href="{{ url_for('projects.view', project_url=project.url) }}")
 | 
					 | 
				
			||||||
				| Browse Project
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		li.jstree-node(data-node-type="page")
 | 
					 | 
				
			||||||
			a.jstree-anchor(
 | 
					 | 
				
			||||||
			href="{{ url_for('main.project_blog', project_url=project.url) }}") Blog
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		| {% for post in posts %}
 | 
					 | 
				
			||||||
		li.jstree-node
 | 
					 | 
				
			||||||
			a.jstree-anchor.tree-item.post(
 | 
					 | 
				
			||||||
			href="{{ url_for_node(node=post) }}",
 | 
					 | 
				
			||||||
			class="{% if post._id == node._id %}jstree-clicked{% endif %}")
 | 
					 | 
				
			||||||
				.tree-item-thumbnail
 | 
					 | 
				
			||||||
					| {% if post.picture %}
 | 
					 | 
				
			||||||
					img(src="{{ post.picture.thumbnail('s', api=api) }}")
 | 
					 | 
				
			||||||
					| {% else %}
 | 
					 | 
				
			||||||
					i.pi-document-text
 | 
					 | 
				
			||||||
					| {% endif %}
 | 
					 | 
				
			||||||
				span.tree-item-title {{ post.name }}
 | 
					 | 
				
			||||||
				span.tree-item-info {{ post._created | pretty_date }}
 | 
					 | 
				
			||||||
		| {% endfor %}
 | 
					 | 
				
			||||||
| {% endblock %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
| {% block footer_scripts %}
 | 
					 | 
				
			||||||
script.
 | 
					 | 
				
			||||||
	ProjectUtils.setProjectAttributes({projectId: "{{project._id}}", isProject: false, nodeId: '{{node._id}}'});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	/* UI Stuff */
 | 
					 | 
				
			||||||
	var project_container = document.getElementById('project-container');
 | 
					 | 
				
			||||||
	$(window).on("load resize",function(){
 | 
					 | 
				
			||||||
		containerResizeY($(window).height());
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		if ($(window).width() > 480) {
 | 
					 | 
				
			||||||
			project_container.style.height = (window.innerHeight - project_container.offsetTop) + "px";
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
| {% endblock footer_scripts %}
 | 
					 | 
				
			||||||
@@ -1,9 +0,0 @@
 | 
				
			|||||||
| {% import 'nodes/custom/blog/_macros.html' as blogmacros %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
| {{ blogmacros.render_blog_post(node, project=project) }}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#comments-embed.comments-compact
 | 
					 | 
				
			||||||
	.comments-list-loading
 | 
					 | 
				
			||||||
		i.pi-spin
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
include ../_scripts
 | 
					 | 
				
			||||||
@@ -18,11 +18,6 @@ meta(property="og:image", content="{{ node.picture.thumbnail('l', api=api) }}")
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
| {% block page_title %}{{node.name}} - Blog{% endblock%}
 | 
					| {% block page_title %}{{node.name}} - Blog{% endblock%}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| {% block css %}
 | 
					 | 
				
			||||||
| {{ super() }}
 | 
					 | 
				
			||||||
link(href="{{ url_for('static_cloud', filename='assets/css/project-landing.css') }}", rel="stylesheet")
 | 
					 | 
				
			||||||
| {% endblock css %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
| {% set title = 'blog' %}
 | 
					| {% set title = 'blog' %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| {% block body %}
 | 
					| {% block body %}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -68,7 +68,7 @@ script.
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		#stats.search-list-stats
 | 
							#stats.search-list-stats
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		+card-deck()(id='hits', class="h-100 m-0 pt-3 pr-2 card-deck-horizontal")
 | 
							+card-deck()(id='hits', class="h-100 m-0 pt-3 pr-2 card-deck-vertical")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	#search-details.border-left.search-details
 | 
						#search-details.border-left.search-details
 | 
				
			||||||
		#search-error
 | 
							#search-error
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -25,12 +25,11 @@
 | 
				
			|||||||
			| {{ node | markdowned('description') }}
 | 
								| {{ node | markdowned('description') }}
 | 
				
			||||||
		| {% endif %}
 | 
							| {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
	| {# DETAILS #}
 | 
						| {# DETAILS #}
 | 
				
			||||||
	section.node-details-meta.px-4.py-2
 | 
						section.node-details-meta.pl-4.pr-2.py-2.border-bottom
 | 
				
			||||||
		ul.list-unstyled.m-0
 | 
							ul.list-unstyled.m-0
 | 
				
			||||||
			| {% if node.properties.license_type %}
 | 
								| {% if node.properties.license_type %}
 | 
				
			||||||
			li
 | 
								li.px-2
 | 
				
			||||||
				a.node-details-license(
 | 
									a.node-details-license(
 | 
				
			||||||
					href="https://creativecommons.org/licenses/",
 | 
										href="https://creativecommons.org/licenses/",
 | 
				
			||||||
					target="_blank",
 | 
										target="_blank",
 | 
				
			||||||
@@ -41,15 +40,15 @@
 | 
				
			|||||||
			| {% endif %}
 | 
								| {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			| {% if node.has_method('PUT') and (node.properties.status != 'published') %}
 | 
								| {% if node.has_method('PUT') and (node.properties.status != 'published') %}
 | 
				
			||||||
			li(class="status-{{ node.properties.status }}")
 | 
								li.px-2(class="status-{{ node.properties.status }}")
 | 
				
			||||||
				| {{ node.properties.status | undertitle }}
 | 
									| {{ node.properties.status | undertitle }}
 | 
				
			||||||
			| {% endif %}
 | 
								| {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			li(title="Author")
 | 
								li.px-2(title="Author")
 | 
				
			||||||
				| {{ node.user.full_name }}
 | 
									| {{ node.user.full_name }}
 | 
				
			||||||
				| {{ node.user.badges.html|safe }}
 | 
									| {{ node.user.badges.html|safe }}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			li(
 | 
								li.px-2(
 | 
				
			||||||
				title="Created {{ node._created }} (updated {{ node._updated | pretty_date_time }})")
 | 
									title="Created {{ node._created }} (updated {{ node._updated | pretty_date_time }})")
 | 
				
			||||||
				| {{ node._created | pretty_date }}
 | 
									| {{ node._created | pretty_date }}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -60,14 +59,12 @@
 | 
				
			|||||||
					| Shared
 | 
										| Shared
 | 
				
			||||||
			| {% endif %}
 | 
								| {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								li.ml-auto
 | 
				
			||||||
 | 
					 | 
				
			||||||
			li.left-side
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
			| {% if node.file %}
 | 
								| {% if node.file %}
 | 
				
			||||||
			li(title="File size")
 | 
								li.px-2(title="File size")
 | 
				
			||||||
				| {{ node.file.length | filesizeformat }}
 | 
									| {{ node.file.length | filesizeformat }}
 | 
				
			||||||
			li.js-type(title="File format")
 | 
								li.px-2.js-type(title="File format")
 | 
				
			||||||
				| {{ node.file.content_type }}
 | 
									| {{ node.file.content_type }}
 | 
				
			||||||
			| {% endif %}
 | 
								| {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -95,11 +92,11 @@
 | 
				
			|||||||
				| {% endblock node_download %}
 | 
									| {% endblock node_download %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
				| {% elif current_user.has_cap('can-renew-subscription') %}
 | 
									| {% elif current_user.has_cap('can-renew-subscription') %}
 | 
				
			||||||
				a.btn.btn-success(
 | 
									a.btn.btn-outline-primary(
 | 
				
			||||||
					title="Renew your subscription to download",
 | 
										title="Renew your subscription to download",
 | 
				
			||||||
					target="_blank",
 | 
										target="_blank",
 | 
				
			||||||
					href="/renew")
 | 
										href="/renew")
 | 
				
			||||||
					i.pi-heart
 | 
										i.pi-heart.pr-2
 | 
				
			||||||
					| Renew Subscription
 | 
										| Renew Subscription
 | 
				
			||||||
 | 
					
 | 
				
			||||||
				| {% elif current_user.is_authenticated %}
 | 
									| {% elif current_user.is_authenticated %}
 | 
				
			||||||
@@ -116,12 +113,32 @@
 | 
				
			|||||||
				| {% endif %}
 | 
									| {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	| {% endblock node_details %}
 | 
						| {% endblock node_details %}
 | 
				
			||||||
 | 
					.container-fluid
 | 
				
			||||||
 | 
						.row
 | 
				
			||||||
 | 
							| {% block node_comments %}
 | 
				
			||||||
 | 
							.col-md-8.col-sm-12
 | 
				
			||||||
 | 
								#comments-embed
 | 
				
			||||||
 | 
									.comments-list-loading
 | 
				
			||||||
 | 
										i.pi-spin
 | 
				
			||||||
 | 
							| {% endblock node_comments %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	| {% block node_comments %}
 | 
							| {% if node.properties.tags %}
 | 
				
			||||||
	#comments-embed
 | 
							.col-md-4.d-none.d-lg-block
 | 
				
			||||||
		.comments-list-loading
 | 
								script(src="{{ url_for('static_cloud', filename='assets/js/tagged_assets.min.js') }}")
 | 
				
			||||||
			i.pi-spin
 | 
								script.
 | 
				
			||||||
	| {% endblock node_comments %}
 | 
									$(function() {
 | 
				
			||||||
 | 
										$('.js-asset-list').loadTaggedAssets(4, 0);
 | 
				
			||||||
 | 
									})
 | 
				
			||||||
 | 
								.tagged-similar.p-3
 | 
				
			||||||
 | 
									h6 Similar assets
 | 
				
			||||||
 | 
									| {% for tag in node.properties.tags[:3] %}
 | 
				
			||||||
 | 
									| {% if loop.index < 4 %}
 | 
				
			||||||
 | 
									.card-deck.card-padless.card-deck-vertical.mx-0(
 | 
				
			||||||
 | 
											class="js-asset-list",
 | 
				
			||||||
 | 
											data-asset-tag="{{ tag }}")
 | 
				
			||||||
 | 
									| {% endif %}
 | 
				
			||||||
 | 
									| {% endfor %}
 | 
				
			||||||
 | 
							| {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| {% include 'nodes/custom/_scripts.html' %}
 | 
					| {% include 'nodes/custom/_scripts.html' %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,41 +1,50 @@
 | 
				
			|||||||
| {% macro render_secondary_navigation(project, pages=None) %}
 | 
					include ../mixins/components
 | 
				
			||||||
nav.navbar-secondary
 | 
					
 | 
				
			||||||
	nav.collapse.navbar-collapse
 | 
					| {% macro render_secondary_navigation(project, navigation_links, title) %}
 | 
				
			||||||
		ul.navbar-nav.navbar-right
 | 
					
 | 
				
			||||||
			li
 | 
					| {% if project.category == 'course' %}
 | 
				
			||||||
				a.navbar-item(
 | 
					| {% set category_url = url_for('cloud.courses') %}
 | 
				
			||||||
					href="{{ url_for('projects.view', project_url=project.url) }}",
 | 
					| {% elif project.category == 'workshop' %}
 | 
				
			||||||
					title="{{ project.name }} Homepage")
 | 
					| {% set category_url = url_for('cloud.workshops') %}
 | 
				
			||||||
					span
 | 
					| {% elif project.category == 'film' %}
 | 
				
			||||||
						b {{ project.name }}
 | 
					| {% set category_url = url_for('cloud.open_projects') %}
 | 
				
			||||||
			li
 | 
					| {% else %}
 | 
				
			||||||
				a.navbar-item(
 | 
					| {% set category_url = url_for('main.homepage') %}
 | 
				
			||||||
				href="{{ url_for('main.project_blog', project_url=project.url) }}",
 | 
					| {% endif %}
 | 
				
			||||||
				title="Project Blog",
 | 
					
 | 
				
			||||||
				class="{% if category == 'blog' %}active{% endif %}")
 | 
					+nav-secondary()
 | 
				
			||||||
					span Blog
 | 
						| {% if project.url != 'blender-cloud' %}
 | 
				
			||||||
			| {% if pages %}
 | 
						| {% if not project.is_private %}
 | 
				
			||||||
			| {% for p in pages %}
 | 
						li.text-capitalize
 | 
				
			||||||
			li
 | 
							a.nav-link.text-muted.px-0(href="{{ category_url }}")
 | 
				
			||||||
				a.navbar-item(
 | 
								span {{ project.category }}
 | 
				
			||||||
				href="{{ url_for('projects.view_node', project_url=project.url, node_id=p._id) }}",
 | 
						li.px-1
 | 
				
			||||||
				title="{{ p.name }}",
 | 
							i.pi-angle-right
 | 
				
			||||||
				class="{% if category == 'page' %}active{% endif %}")
 | 
						| {% endif %}
 | 
				
			||||||
					span {{ p.name }}
 | 
					
 | 
				
			||||||
			| {% endfor %}
 | 
						+nav-secondary-link(
 | 
				
			||||||
			| {% endif %}
 | 
							class="px-1 font-weight-bold",
 | 
				
			||||||
			| {% if project.nodes_featured %}
 | 
							href="{{url_for('projects.view', project_url=project.url, _external=True)}}")
 | 
				
			||||||
			| {# In some cases featured_nodes might might be embedded #}
 | 
							span {{ project.name }}
 | 
				
			||||||
			| {% if '_id' in project.nodes_featured[0] %}
 | 
						| {% endif %}
 | 
				
			||||||
			| {% set featured_node_id=project.nodes_featured[0]._id %}
 | 
					
 | 
				
			||||||
			| {% else %}
 | 
						| {% for link in navigation_links %}
 | 
				
			||||||
			| {% set featured_node_id=project.nodes_featured[0] %}
 | 
						+nav-secondary-link(href="{{ link['url'] }}")
 | 
				
			||||||
			| {% endif %}
 | 
							| {{ link['label'] }}
 | 
				
			||||||
			li
 | 
						| {% endfor %}
 | 
				
			||||||
				a.navbar-item(
 | 
					
 | 
				
			||||||
				href="{{ url_for('projects.view_node', project_url=project.url, node_id=featured_node_id) }}",
 | 
						| {% if project.nodes_featured %}
 | 
				
			||||||
				title="Explore {{ project.name }}",
 | 
						| {# In some cases featured_nodes might might be embedded #}
 | 
				
			||||||
				class="{% if category == 'blog' %}active{% endif %}")
 | 
						| {% if '_id' in project.nodes_featured[0] %}
 | 
				
			||||||
					span Explore
 | 
						| {% set featured_node_id=project.nodes_featured[0]._id %}
 | 
				
			||||||
			| {% endif %}
 | 
						| {% else %}
 | 
				
			||||||
 | 
						| {% set featured_node_id=project.nodes_featured[0] %}
 | 
				
			||||||
 | 
						| {% endif %}
 | 
				
			||||||
 | 
						+nav-secondary-link(
 | 
				
			||||||
 | 
								href="{{ url_for('projects.view_node', project_url=project.url, node_id=featured_node_id) }}",
 | 
				
			||||||
 | 
								title="Explore {{ project.name }}",
 | 
				
			||||||
 | 
								class="{% if title == 'project' %}active{% endif %}")
 | 
				
			||||||
 | 
								span Explore
 | 
				
			||||||
 | 
						| {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| {% endmacro %}
 | 
					| {% endmacro %}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -29,7 +29,7 @@
 | 
				
			|||||||
.container-fluid
 | 
					.container-fluid
 | 
				
			||||||
	.row
 | 
						.row
 | 
				
			||||||
		.col-md-12
 | 
							.col-md-12
 | 
				
			||||||
			h5.pl-2.mb-0 Project Overview
 | 
								h5.pl-2.mb-0.pt-3 Project Overview
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#node-edit-container
 | 
					#node-edit-container
 | 
				
			||||||
	form(
 | 
						form(
 | 
				
			||||||
@@ -122,7 +122,6 @@ script(type="text/javascript").
 | 
				
			|||||||
	$('.project-mode-edit').displayAs('inline-block');
 | 
						$('.project-mode-edit').displayAs('inline-block');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	ProjectUtils.setProjectAttributes({projectId: "{{project._id}}", isProject: true, nodeId: ''});
 | 
						ProjectUtils.setProjectAttributes({projectId: "{{project._id}}", isProject: true, nodeId: ''});
 | 
				
			||||||
	var convert = new Markdown.getSanitizingConverter().makeHtml;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	$('.button-save').on('click', function(e){
 | 
						$('.button-save').on('click', function(e){
 | 
				
			||||||
		e.preventDefault();
 | 
							e.preventDefault();
 | 
				
			||||||
@@ -136,36 +135,36 @@ script(type="text/javascript").
 | 
				
			|||||||
	/* Build the markdown preview when typing in textarea */
 | 
						/* Build the markdown preview when typing in textarea */
 | 
				
			||||||
	$(function() {
 | 
						$(function() {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		var $textarea = $('.form-group.description textarea'),
 | 
							var $contentField = $('.form-group.description textarea'),
 | 
				
			||||||
				$loader = $('<div class="md-preview-loading"><i class="pi-spin spin"></i></div>').insertAfter($textarea),
 | 
									$contentPreview = $('<div class="node-edit-form-md-preview" />').insertAfter($contentField);
 | 
				
			||||||
				$preview = $('<div class="node-edit-form-md-preview" />').insertAfter($loader);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
		$loader.hide();
 | 
							function parseDescriptionContent(content) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Delay function to not start converting heavy posts immediately
 | 
								$.ajax({
 | 
				
			||||||
		var delay = (function(){
 | 
									url: "{{ url_for('nodes.preview_markdown')}}",
 | 
				
			||||||
			var timer = 0;
 | 
									type: 'post',
 | 
				
			||||||
			return function(callback, ms){
 | 
									data: {content: content},
 | 
				
			||||||
				clearTimeout (timer);
 | 
									headers: {"X-CSRFToken": csrf_token},
 | 
				
			||||||
				timer = setTimeout(callback, ms);
 | 
									headers: {},
 | 
				
			||||||
			};
 | 
									dataType: 'json'
 | 
				
			||||||
		})();
 | 
								})
 | 
				
			||||||
 | 
								.done(function (data) {
 | 
				
			||||||
 | 
									$contentPreview.html(data.content);
 | 
				
			||||||
 | 
								})
 | 
				
			||||||
 | 
								.fail(function (err) {
 | 
				
			||||||
 | 
									toastr.error(xhrErrorResponseMessage(err), 'Parsing failed');
 | 
				
			||||||
 | 
								});
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		$textarea.keyup(function() {
 | 
							var options = {
 | 
				
			||||||
			/* If there's an iframe (YouTube embed), delay markdown convert 1.5s */
 | 
								callback: parseDescriptionContent,
 | 
				
			||||||
			if (/iframe/i.test($textarea.val())) {
 | 
								wait: 750,
 | 
				
			||||||
				$loader.show();
 | 
								highlight: false,
 | 
				
			||||||
 | 
								allowSubmit: false,
 | 
				
			||||||
 | 
								captureLength: 2
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
				delay(function(){
 | 
							$contentField.typeWatch(options);
 | 
				
			||||||
					// Convert markdown
 | 
					 | 
				
			||||||
					$preview.html(convert($textarea.val()));
 | 
					 | 
				
			||||||
					$loader.hide();
 | 
					 | 
				
			||||||
				}, 1500 );
 | 
					 | 
				
			||||||
			} else {
 | 
					 | 
				
			||||||
				// Convert markdown
 | 
					 | 
				
			||||||
				$preview.html(convert($textarea.val()));
 | 
					 | 
				
			||||||
			};
 | 
					 | 
				
			||||||
		}).trigger('keyup');
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
		$('input, textarea').keypress(function () {
 | 
							$('input, textarea').keypress(function () {
 | 
				
			||||||
			// Unused: save status of the page as 'edited'
 | 
								// Unused: save status of the page as 'edited'
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -45,7 +45,7 @@ include ../mixins/components
 | 
				
			|||||||
		#project_nav
 | 
							#project_nav
 | 
				
			||||||
			#project_nav-container
 | 
								#project_nav-container
 | 
				
			||||||
				// TODO - make list a macro
 | 
									// TODO - make list a macro
 | 
				
			||||||
				#project_tree.edit.bg-white
 | 
									#project_tree.edit.bg-light
 | 
				
			||||||
					+nav-secondary()(class="nav-secondary-vertical")
 | 
										+nav-secondary()(class="nav-secondary-vertical")
 | 
				
			||||||
						+nav-secondary-link(
 | 
											+nav-secondary-link(
 | 
				
			||||||
							class="{% if title == 'edit' %}active{% endif %}",
 | 
												class="{% if title == 'edit' %}active{% endif %}",
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,36 +1,34 @@
 | 
				
			|||||||
| {% extends 'layout.html' %}
 | 
					| {% extends 'layout.html' %}
 | 
				
			||||||
 | 
					include ../../mixins/components
 | 
				
			||||||
 | 
					
 | 
				
			||||||
//- Don't extend this base file directly. Instead, extend page.html so that Pillar extensions
 | 
					//- Don't extend this base file directly. Instead, extend page.html so that Pillar extensions
 | 
				
			||||||
//- can provide overrides.
 | 
					//- can provide overrides.
 | 
				
			||||||
| {% block body %}
 | 
					| {% block body %}
 | 
				
			||||||
.container
 | 
					.container.py-4
 | 
				
			||||||
	#settings.d-flex.py-4.flex-xs-column
 | 
						.row
 | 
				
			||||||
		#settings-sidebar
 | 
							.col-md-3
 | 
				
			||||||
			| {% block settings_sidebar %}
 | 
								| {% block settings_sidebar %}
 | 
				
			||||||
			.settings-header
 | 
								+nav-secondary('Settings')(class="nav-secondary-vertical bg-light")
 | 
				
			||||||
				.settings-title Settings
 | 
									| {% block settings_sidebar_menu %}
 | 
				
			||||||
			.settings-content
 | 
									+nav-secondary-link(
 | 
				
			||||||
				ul
 | 
										class="{% if title == 'profile' %}active{% endif %} border-top",
 | 
				
			||||||
					| {% block settings_sidebar_menu %}
 | 
										href="{{ url_for('settings.profile') }}")
 | 
				
			||||||
					a(class="{% if title == 'profile' %}active{% endif %}",
 | 
										i.pr-3.pi-vcard
 | 
				
			||||||
						href="{{ url_for('settings.profile') }}")
 | 
										span Profile
 | 
				
			||||||
						li
 | 
									| {% endblock settings_sidebar_menu %}
 | 
				
			||||||
							i.pi-vcard
 | 
					
 | 
				
			||||||
							| Profile
 | 
									| {% block settings_sidebar_menu_bottom %}
 | 
				
			||||||
					| {% endblock settings_sidebar_menu %}
 | 
									+nav-secondary-link(
 | 
				
			||||||
					| {% block settings_sidebar_menu_bottom %}
 | 
										class="{% if title == 'roles' %}active{% endif %}",
 | 
				
			||||||
					a(class="{% if title == 'roles' %}active{% endif %}",
 | 
										href="{{ url_for('settings.roles') }}")
 | 
				
			||||||
						href="{{ url_for('settings.roles') }}")
 | 
										i.pr-3.pi-cog
 | 
				
			||||||
						li
 | 
										span Roles & Capabilities
 | 
				
			||||||
							i.pi-cog
 | 
									| {% endblock settings_sidebar_menu_bottom %}
 | 
				
			||||||
							| Roles & Capabilities
 | 
					 | 
				
			||||||
					| {% endblock settings_sidebar_menu_bottom %}
 | 
					 | 
				
			||||||
			| {% endblock %}
 | 
								| {% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		#settings-container
 | 
							.col-md-9
 | 
				
			||||||
			.settings-header
 | 
								h3.py-1 {% block settings_page_title %}{{ _("Title not set") }}{% endblock %}
 | 
				
			||||||
				.settings-title {% block settings_page_title %}{{ _("Title not set") }}{% endblock %}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
			.settings-content
 | 
								| {% block settings_page_content %}No content set, update your template.{% endblock %}
 | 
				
			||||||
				| {% block settings_page_content %}No content set, update your template.{% endblock %}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
| {% endblock %}
 | 
					| {% endblock %}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -21,7 +21,7 @@ style.
 | 
				
			|||||||
| {% block settings_page_content %}
 | 
					| {% block settings_page_content %}
 | 
				
			||||||
.settings-form
 | 
					.settings-form
 | 
				
			||||||
	form#settings-form(method='POST', action="{{url_for('settings.profile')}}")
 | 
						form#settings-form(method='POST', action="{{url_for('settings.profile')}}")
 | 
				
			||||||
		.left
 | 
							.pb-3
 | 
				
			||||||
			.form-group
 | 
								.form-group
 | 
				
			||||||
				| {{ form.username.label }}
 | 
									| {{ form.username.label }}
 | 
				
			||||||
				| {{ form.username(size=20, class='form-control') }}
 | 
									| {{ form.username(size=20, class='form-control') }}
 | 
				
			||||||
@@ -45,14 +45,13 @@ style.
 | 
				
			|||||||
				| {{ current_user.badges_html|safe }}
 | 
									| {{ current_user.badges_html|safe }}
 | 
				
			||||||
				p.hint-text Note that updates to these badges may take a few minutes to be visible here.
 | 
									p.hint-text Note that updates to these badges may take a few minutes to be visible here.
 | 
				
			||||||
			| {% endif %}
 | 
								| {% endif %}
 | 
				
			||||||
		.right
 | 
							.py-3
 | 
				
			||||||
			.settings-avatar
 | 
								a(href="https://gravatar.com/")
 | 
				
			||||||
				a(href="https://gravatar.com/")
 | 
									img.rounded-circle(src="{{ current_user.gravatar }}")
 | 
				
			||||||
					img(src="{{ current_user.gravatar }}")
 | 
									span.p-3 {{ _("Change Gravatar") }}
 | 
				
			||||||
					span {{ _("Change Gravatar") }}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
		.buttons
 | 
							.py-3
 | 
				
			||||||
			button.btn.btn-outline-success.button-submit(type='submit')
 | 
								button.btn.btn-outline-success.px-5.button-submit(type='submit')
 | 
				
			||||||
				i.pi-check
 | 
									i.pi-check.pr-2
 | 
				
			||||||
				| {{ _("Save Changes") }}
 | 
									| {{ _("Save Changes") }}
 | 
				
			||||||
| {% endblock %}
 | 
					| {% endblock %}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -479,61 +479,65 @@ class TextureSortFilesTest(AbstractPillarTest):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TaggedNodesTest(AbstractPillarTest):
 | 
					class TaggedNodesTest(AbstractPillarTest):
 | 
				
			||||||
    def test_tagged_nodes_api(self):
 | 
					    def setUp(self, **kwargs):
 | 
				
			||||||
 | 
					        super().setUp(**kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.pid, _ = self.ensure_project_exists()
 | 
				
			||||||
 | 
					        self.file_id, _ = self.ensure_file_exists()
 | 
				
			||||||
 | 
					        self.uid = self.create_user()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        from pillar.api.utils import utcnow
 | 
					        from pillar.api.utils import utcnow
 | 
				
			||||||
 | 
					        self.fake_now = utcnow()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_tagged_nodes_api(self):
 | 
				
			||||||
        from datetime import timedelta
 | 
					        from datetime import timedelta
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        pid, _ = self.ensure_project_exists()
 | 
					 | 
				
			||||||
        file_id, _ = self.ensure_file_exists()
 | 
					 | 
				
			||||||
        uid = self.create_user()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        now = utcnow()
 | 
					 | 
				
			||||||
        base_node = {
 | 
					        base_node = {
 | 
				
			||||||
            'name': 'Just a node name',
 | 
					            'name': 'Just a node name',
 | 
				
			||||||
            'project': pid,
 | 
					            'project': self.pid,
 | 
				
			||||||
            'description': '',
 | 
					            'description': '',
 | 
				
			||||||
            'node_type': 'asset',
 | 
					            'node_type': 'asset',
 | 
				
			||||||
            'user': uid,
 | 
					            'user': self.uid,
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        base_props = {'status': 'published',
 | 
					        base_props = {'status': 'published',
 | 
				
			||||||
                      'file': file_id,
 | 
					                      'file': self.file_id,
 | 
				
			||||||
                      'content_type': 'video',
 | 
					                      'content_type': 'video',
 | 
				
			||||||
                      'order': 0}
 | 
					                      'order': 0}
 | 
				
			||||||
        # No tags, should never be returned.
 | 
					        # No tags, should never be returned.
 | 
				
			||||||
        self.create_node({
 | 
					        self.create_node({
 | 
				
			||||||
            '_created': now,
 | 
					            '_created': self.fake_now,
 | 
				
			||||||
            'properties': base_props,
 | 
					            'properties': base_props,
 | 
				
			||||||
            **base_node})
 | 
					            **base_node})
 | 
				
			||||||
        # Empty tag list, should never be returned.
 | 
					        # Empty tag list, should never be returned.
 | 
				
			||||||
        self.create_node({
 | 
					        self.create_node({
 | 
				
			||||||
            '_created': now + timedelta(seconds=1),
 | 
					            '_created': self.fake_now + timedelta(seconds=1),
 | 
				
			||||||
            'properties': {'tags': [], **base_props},
 | 
					            'properties': {'tags': [], **base_props},
 | 
				
			||||||
            **base_node})
 | 
					            **base_node})
 | 
				
			||||||
        # Empty string as tag, should never be returned.
 | 
					        # Empty string as tag, should never be returned.
 | 
				
			||||||
        self.create_node({
 | 
					        self.create_node({
 | 
				
			||||||
            '_created': now + timedelta(seconds=1),
 | 
					            '_created': self.fake_now + timedelta(seconds=1),
 | 
				
			||||||
            'properties': {'tags': [''], **base_props},
 | 
					            'properties': {'tags': [''], **base_props},
 | 
				
			||||||
            **base_node})
 | 
					            **base_node})
 | 
				
			||||||
        nid_single_tag = self.create_node({
 | 
					        nid_single_tag = self.create_node({
 | 
				
			||||||
            '_created': now + timedelta(seconds=2),
 | 
					            '_created': self.fake_now + timedelta(seconds=2),
 | 
				
			||||||
            # 'एनिमेशन' is 'animation' in Hindi.
 | 
					            # 'एनिमेशन' is 'animation' in Hindi.
 | 
				
			||||||
            'properties': {'tags': ['एनिमेशन'], **base_props},
 | 
					            'properties': {'tags': ['एनिमेशन'], **base_props},
 | 
				
			||||||
            **base_node,
 | 
					            **base_node,
 | 
				
			||||||
        })
 | 
					        })
 | 
				
			||||||
        nid_double_tag = self.create_node({
 | 
					        nid_double_tag = self.create_node({
 | 
				
			||||||
            '_created': now + timedelta(hours=3),
 | 
					            '_created': self.fake_now + timedelta(hours=3),
 | 
				
			||||||
            'properties': {'tags': ['एनिमेशन', 'rigging'], **base_props},
 | 
					            'properties': {'tags': ['एनिमेशन', 'rigging'], **base_props},
 | 
				
			||||||
            **base_node,
 | 
					            **base_node,
 | 
				
			||||||
        })
 | 
					        })
 | 
				
			||||||
        nid_other_tag = self.create_node({
 | 
					        nid_other_tag = self.create_node({
 | 
				
			||||||
            '_deleted': False,
 | 
					            '_deleted': False,
 | 
				
			||||||
            '_created': now + timedelta(days=4),
 | 
					            '_created': self.fake_now + timedelta(days=4),
 | 
				
			||||||
            'properties': {'tags': ['producción'], **base_props},
 | 
					            'properties': {'tags': ['producción'], **base_props},
 | 
				
			||||||
            **base_node,
 | 
					            **base_node,
 | 
				
			||||||
        })
 | 
					        })
 | 
				
			||||||
        # Matching tag but deleted node, should never be returned.
 | 
					        # Matching tag but deleted node, should never be returned.
 | 
				
			||||||
        self.create_node({
 | 
					        self.create_node({
 | 
				
			||||||
            '_created': now + timedelta(seconds=1),
 | 
					            '_created': self.fake_now + timedelta(seconds=1),
 | 
				
			||||||
            '_deleted': True,
 | 
					            '_deleted': True,
 | 
				
			||||||
            'properties': {'tags': ['एनिमेशन'], **base_props},
 | 
					            'properties': {'tags': ['एनिमेशन'], **base_props},
 | 
				
			||||||
            **base_node})
 | 
					            **base_node})
 | 
				
			||||||
@@ -556,3 +560,76 @@ class TaggedNodesTest(AbstractPillarTest):
 | 
				
			|||||||
        with self.app.app_context():
 | 
					        with self.app.app_context():
 | 
				
			||||||
            invalid_url = flask.url_for('nodes_api.tagged', tag='')
 | 
					            invalid_url = flask.url_for('nodes_api.tagged', tag='')
 | 
				
			||||||
            self.get(invalid_url, expected_status=404)
 | 
					            self.get(invalid_url, expected_status=404)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_tagged_nodes_asset_video_with_progress_api(self):
 | 
				
			||||||
 | 
					        from datetime import timedelta
 | 
				
			||||||
 | 
					        from pillar.auth import current_user
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        base_node = {
 | 
				
			||||||
 | 
					            'name': 'Spring hair rig setup',
 | 
				
			||||||
 | 
					            'project': self.pid,
 | 
				
			||||||
 | 
					            'description': '',
 | 
				
			||||||
 | 
					            'node_type': 'asset',
 | 
				
			||||||
 | 
					            'user': self.uid,
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        base_props = {'status': 'published',
 | 
				
			||||||
 | 
					                      'file': self.file_id,
 | 
				
			||||||
 | 
					                      'content_type': 'video',
 | 
				
			||||||
 | 
					                      'order': 0}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Create one node of type asset video
 | 
				
			||||||
 | 
					        nid_single_tag = self.create_node({
 | 
				
			||||||
 | 
					            '_created': self.fake_now + timedelta(seconds=2),
 | 
				
			||||||
 | 
					            # 'एनिमेशन' is 'animation' in Hindi.
 | 
				
			||||||
 | 
					            'properties': {'tags': ['एनिमेशन'], **base_props},
 | 
				
			||||||
 | 
					            **base_node,
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Create another node
 | 
				
			||||||
 | 
					        self.create_node({
 | 
				
			||||||
 | 
					            '_created': self.fake_now + timedelta(seconds=2),
 | 
				
			||||||
 | 
					            # 'एनिमेशन' is 'animation' in Hindi.
 | 
				
			||||||
 | 
					            'properties': {'tags': ['एनिमेशन'], **base_props},
 | 
				
			||||||
 | 
					            **base_node,
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Add video watch progress for the self.uid user
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            users_coll = self.app.db('users')
 | 
				
			||||||
 | 
					            # Define video progress
 | 
				
			||||||
 | 
					            progress_in_sec = 333
 | 
				
			||||||
 | 
					            video_progress = {
 | 
				
			||||||
 | 
					                'progress_in_sec': progress_in_sec,
 | 
				
			||||||
 | 
					                'progress_in_percent': 70,
 | 
				
			||||||
 | 
					                'done': False,
 | 
				
			||||||
 | 
					                'last_watched': self.fake_now + timedelta(seconds=2),
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            users_coll.update_one(
 | 
				
			||||||
 | 
					                {'_id': self.uid},
 | 
				
			||||||
 | 
					                {'$set': {f'nodes.view_progress.{nid_single_tag}': video_progress}})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Utility to fetch tagged nodes and return them as JSON list
 | 
				
			||||||
 | 
					        def do_query():
 | 
				
			||||||
 | 
					            animation_tags_url = flask.url_for('nodes_api.tagged', tag='एनिमेशन')
 | 
				
			||||||
 | 
					            return self.get(animation_tags_url).json
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Ensure that anonymous users get videos with no view_progress info
 | 
				
			||||||
 | 
					        with self.app.app_context():
 | 
				
			||||||
 | 
					            resp = do_query()
 | 
				
			||||||
 | 
					            for node in resp:
 | 
				
			||||||
 | 
					                self.assertNotIn('view_progress', node)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Ensure that an authenticated user gets view_progress info if the video was watched
 | 
				
			||||||
 | 
					        with self.login_as(self.uid):
 | 
				
			||||||
 | 
					            resp = do_query()
 | 
				
			||||||
 | 
					            for node in resp:
 | 
				
			||||||
 | 
					                if node['_id'] in current_user.nodes['view_progress']:
 | 
				
			||||||
 | 
					                    self.assertIn('view_progress', node)
 | 
				
			||||||
 | 
					                    self.assertEqual(progress_in_sec, node['view_progress']['progress_in_sec'])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Ensure that another user with no view progress does not get any view progress info
 | 
				
			||||||
 | 
					        other_user = self.create_user(user_id=ObjectId())
 | 
				
			||||||
 | 
					        with self.login_as(other_user):
 | 
				
			||||||
 | 
					            resp = do_query()
 | 
				
			||||||
 | 
					            for node in resp:
 | 
				
			||||||
 | 
					                self.assertNotIn('view_progress', node)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -187,37 +187,48 @@ class AttachmentTest(AbstractPillarTest):
 | 
				
			|||||||
            ],
 | 
					            ],
 | 
				
			||||||
            'filename': 'cute_kitten.jpg',
 | 
					            'filename': 'cute_kitten.jpg',
 | 
				
			||||||
        })
 | 
					        })
 | 
				
			||||||
        node_doc = {'properties': {
 | 
					
 | 
				
			||||||
            'attachments': {
 | 
					        node_properties = {'attachments': {
 | 
				
			||||||
                'img': {'oid': oid},
 | 
					            'img': {'oid': oid},
 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }}
 | 
					        }}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        node_doc = {'properties': node_properties}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Collect the two possible context that can be provided for attachemt
 | 
				
			||||||
 | 
					        # rendering. See pillar.shortcodes.sdk_file for more info.
 | 
				
			||||||
 | 
					        possible_contexts = [node_properties, node_doc]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # We have to get the file document again, because retrieving it via the
 | 
					        # We have to get the file document again, because retrieving it via the
 | 
				
			||||||
        # API (which is what the shortcode rendering is doing) will change its
 | 
					        # API (which is what the shortcode rendering is doing) will change its
 | 
				
			||||||
        # link URL.
 | 
					        # link URL.
 | 
				
			||||||
        db_file = self.get(f'/api/files/{oid}').get_json()
 | 
					        db_file = self.get(f'/api/files/{oid}').get_json()
 | 
				
			||||||
        link = db_file['variations'][0]['link']
 | 
					        link = db_file['variations'][0]['link']
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        with self.app.test_request_context():
 | 
					        def do_render(context, link):
 | 
				
			||||||
            self_linked = f'<a class="expand-image-links" href="{link}">' \
 | 
					            """Utility to run attachment rendering in different contexts."""
 | 
				
			||||||
                          f'<img src="{link}" alt="cute_kitten.jpg"/></a>'
 | 
					            with self.app.test_request_context():
 | 
				
			||||||
            self.assertEqual(
 | 
					                self_linked = f'<a class="expand-image-links" href="{link}">' \
 | 
				
			||||||
                self_linked,
 | 
					                              f'<img src="{link}" alt="cute_kitten.jpg"/></a>'
 | 
				
			||||||
                render('{attachment img link}', context=node_doc).strip()
 | 
					                self.assertEqual(
 | 
				
			||||||
            )
 | 
					                    self_linked,
 | 
				
			||||||
            self.assertEqual(
 | 
					                    render('{attachment img link}', context=context).strip()
 | 
				
			||||||
                self_linked,
 | 
					                )
 | 
				
			||||||
                render('{attachment img link=self}', context=node_doc).strip()
 | 
					                self.assertEqual(
 | 
				
			||||||
            )
 | 
					                    self_linked,
 | 
				
			||||||
            self.assertEqual(
 | 
					                    render('{attachment img link=self}', context=context).strip()
 | 
				
			||||||
                f'<img src="{link}" alt="cute_kitten.jpg"/>',
 | 
					                )
 | 
				
			||||||
                render('{attachment img}', context=node_doc).strip()
 | 
					                self.assertEqual(
 | 
				
			||||||
            )
 | 
					                    f'<img src="{link}" alt="cute_kitten.jpg"/>',
 | 
				
			||||||
 | 
					                    render('{attachment img}', context=context).strip()
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            tag_link = 'https://i.imgur.com/FmbuPNe.jpg'
 | 
					                tag_link = 'https://i.imgur.com/FmbuPNe.jpg'
 | 
				
			||||||
            self.assertEqual(
 | 
					                self.assertEqual(
 | 
				
			||||||
                f'<a href="{tag_link}" target="_blank">'
 | 
					                    f'<a href="{tag_link}" target="_blank">'
 | 
				
			||||||
                f'<img src="{link}" alt="cute_kitten.jpg"/></a>',
 | 
					                    f'<img src="{link}" alt="cute_kitten.jpg"/></a>',
 | 
				
			||||||
                render('{attachment img link=%r}' % tag_link, context=node_doc).strip()
 | 
					                    render('{attachment img link=%r}' % tag_link, context=context).strip()
 | 
				
			||||||
            )
 | 
					                )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Test both possible contexts for rendering attachments
 | 
				
			||||||
 | 
					        for context in possible_contexts:
 | 
				
			||||||
 | 
					            do_render(context, link)
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user