29 Commits

Author SHA1 Message Date
77e3c476f0 Move node hooks into own file 2018-09-16 13:04:12 +02:00
842ddaeab0 Assets: Display similar assets based on tags
Experimental.
2018-09-16 06:29:19 +02:00
85e5cb4f71 Projects: Only display category for public projects 2018-09-16 05:02:52 +02:00
6648f8d074 Minor style adjustments 2018-09-16 05:02:16 +02:00
a5bc36b1cf Jumbotron overlay is now optional.
Just add the jumbotron-overlay class, or jumbotron-overlay-gradient
2018-09-16 04:28:11 +02:00
e56b3ec61f Use Pillar's built-in markdown when editing projects/creating posts. 2018-09-16 04:27:24 +02:00
9624f6bd76 Style pages 2018-09-16 04:05:37 +02:00
4e5a53a19b Option to limit card-deck to a maximum N columns
Only 3 supported for now
2018-09-16 03:42:48 +02:00
fbc7c0fce7 CSS: media breakpoints
from Bootstrap and added a couple more for super big screens
2018-09-16 03:39:54 +02:00
bb483e72aa CSS cleanup (blog, comments) 2018-09-16 03:05:34 +02:00
baf27fa560 Blog: Fix and css cleanup 2018-09-16 02:04:14 +02:00
845ba953cb Make YouTube shortcode embeds responsive
Part of T56813
2018-09-15 22:32:03 +02:00
e5b7905a5c Project: Sort navigation links
See T56813
2018-09-15 22:12:12 +02:00
88c0ef0e7c Blog: fixes and tweaks 2018-09-15 21:32:54 +02:00
f8d992400e Extend attachment shortcode rendering
The previous implementation only supported rendering
attachments within the context of a node or project document.
Now it also supports node.properties. This is a temporary
solution, as noted in the TODO comments.
2018-09-15 19:01:58 +02:00
263d68071e Add view_progress to nodes of type asset 2018-09-15 17:59:30 +02:00
0f7f7d5a66 Profile styling, layout and cleanup. 2018-09-15 16:42:29 +02:00
6b29c70212 Navigation menu: Style see-more items 2018-09-15 06:16:06 +02:00
07670dce96 Fix view type list for folders 2018-09-15 05:50:42 +02:00
fe288b1cc2 Typo 2018-09-15 05:50:10 +02:00
2e9555e160 Layout and style for new global menu. 2018-09-15 05:41:15 +02:00
b0311af6b5 CSS: $primary-accent color and gradient utils 2018-09-15 05:40:29 +02:00
35a22cab4b Fix wrong url 2018-09-14 23:12:02 +02:00
0055633732 Blog: Styling and cleanup 2018-09-14 20:30:04 +02:00
78b186c8e4 Blog: Unify all post viewing in one template
During the years we went from site-wide blog, to project blog, to
post view inside a project, to full one-page post view. This led
to have multiple ways to see the same content.

This commit brings all post related stuff to always use index.pug
(or index_archive if we are looking blasts from the past).
2018-09-14 20:29:44 +02:00
232321cc2c Blog: Cleanup CSS 2018-09-14 17:29:13 +02:00
a6d662b690 Refactor render_secondary_navigation macro
* Use navigation_links instead of pages.
* Use secondary navigation mixin.
* Always include project category.
* Always include Explore tab.

Should be eventually moved to Blender Cloud repo.
2018-09-14 16:58:48 +02:00
32c7ffbc99 Move project-main to Blender Cloud
Also remove calls to project-landing, it is now part of project-main.
It was just a few lines of code not worth having a different CSS file.
2018-09-14 16:56:35 +02:00
cfcc629b61 Update package-lock.json 2018-09-14 13:11:49 +02:00
46 changed files with 2155 additions and 2623 deletions

2176
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,12 @@
import base64
import functools
import logging
import typing
import urllib.parse
import pymongo.errors
import werkzeug.exceptions as wz_exceptions
from bson import ObjectId
from flask import current_app, Blueprint, request
import pillar.markdown
from pillar.api.activities import activity_subscribe, activity_object_add
from pillar.api.node_types import PILLAR_NAMED_NODE_TYPES
from pillar.api.file_storage_backends.gcs import update_file_name
from pillar.api.nodes import hooks
from pillar.api.nodes.hooks import short_link_info
from pillar.api.utils import str2id, jsonify
from pillar.api.utils.authorization import check_permissions, require_login
@@ -21,40 +15,6 @@ blueprint = Blueprint('nodes_api', __name__)
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'])
@require_login(require_roles=ROLES_FOR_SHARING)
def share_node(node_id):
@@ -94,13 +54,31 @@ def share_node(node_id):
@blueprint.route('/tagged/<tag>')
def tagged(tag=''):
"""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
# handler on /api/nodes/<node_id> will return a 405 Method Not Allowed.
if not tag:
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):
@@ -129,7 +107,8 @@ def _tagged(tag: str):
{'$sort': {'_created': -1}}
])
return jsonify(list(agg))
return list(agg)
def generate_and_store_short_code(node):
@@ -207,283 +186,6 @@ def create_short_code(node) -> str:
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):
global _tagged
@@ -493,26 +195,26 @@ def setup_app(app, url_prefix):
from . import patch
patch.setup_app(app, url_prefix=url_prefix)
app.on_fetched_item_nodes += before_returning_node
app.on_fetched_resource_nodes += before_returning_nodes
app.on_fetched_item_nodes += hooks.before_returning_node
app.on_fetched_resource_nodes += hooks.before_returning_nodes
app.on_replace_nodes += before_replacing_node
app.on_replace_nodes += parse_markdown
app.on_replace_nodes += texture_sort_files
app.on_replace_nodes += deduct_content_type
app.on_replace_nodes += node_set_default_picture
app.on_replaced_nodes += after_replacing_node
app.on_replace_nodes += hooks.before_replacing_node
app.on_replace_nodes += hooks.parse_markdown
app.on_replace_nodes += hooks.texture_sort_files
app.on_replace_nodes += hooks.deduct_content_type
app.on_replace_nodes += hooks.node_set_default_picture
app.on_replaced_nodes += hooks.after_replacing_node
app.on_insert_nodes += before_inserting_nodes
app.on_insert_nodes += parse_markdowns
app.on_insert_nodes += nodes_deduct_content_type
app.on_insert_nodes += nodes_set_default_picture
app.on_insert_nodes += textures_sort_files
app.on_inserted_nodes += after_inserting_nodes
app.on_insert_nodes += hooks.before_inserting_nodes
app.on_insert_nodes += hooks.parse_markdowns
app.on_insert_nodes += hooks.nodes_deduct_content_type
app.on_insert_nodes += hooks.nodes_set_default_picture
app.on_insert_nodes += hooks.textures_sort_files
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_deleted_item_nodes += after_deleting_node
app.on_delete_item_nodes += hooks.before_deleting_node
app.on_deleted_item_nodes += hooks.after_deleting_node
app.register_api_blueprint(blueprint, url_prefix=url_prefix)

325
pillar/api/nodes/hooks.py Normal file
View 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,
}

View File

@@ -162,9 +162,12 @@ class YouTube:
if not youtube_id:
return html_module.escape('{youtube invalid YouTube ID/URL}')
src = f'https://www.youtube.com/embed/{youtube_id}?rel=0'
html = f'<iframe class="shortcode youtube" width="{width}" height="{height}" src="{src}"' \
f' frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>'
src = f'https://www.youtube.com/embed/{youtube_id}?rel=0'
html = f'<div class="embed-responsive embed-responsive-16by9">' \
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
@@ -225,12 +228,25 @@ class Attachment:
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."""
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)
if not attachment:
raise self.NoSuchSlug(slug)

View File

@@ -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.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'
template_path = f'nodes/custom/blog/{index_arch}{main_project_template}.html',
template_path = f'nodes/custom/blog/{index_arch}.html',
if url:
template_path = f'nodes/custom/post/view{main_project_template}.html',
post = Node.find_one({
'where': {'parent': blog._id, 'properties.url': url},
'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)
# 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:
url_func = functools.partial(url_for, 'main.main_blog_archive')
else:
@@ -121,7 +116,7 @@ def posts_view(project_id=None, project_url=None, url=None, *, archive=False, pa
return render_template(
template_path,
blog=blog,
node=post,
node=post, # node is used by the generic comments rendering (see custom/_scripts.pug)
posts=posts._items,
posts_meta=pmeta,
more_posts_available=pmeta['total'] > pmeta['max_results'],

View File

@@ -1,109 +0,0 @@
(function(vjs) {
"use strict";
var
extend = function(obj) {
var arg, i, k;
for (i = 1; i < arguments.length; i++) {
arg = arguments[i];
for (k in arg) {
if (arg.hasOwnProperty(k)) {
obj[k] = arg[k];
}
}
}
return obj;
},
defaults = {
count: 10,
counter: "counter",
countdown: "countdown",
countdown_text: "Next video in:",
endcard: "player-endcard",
related: "related-content",
next: "next-video",
getRelatedContent: function(callback){callback();},
getNextVid: function(callback){callback();}
},
endcard = function(options) {
var player = this;
var el = this.el();
var settings = extend({}, defaults, options || {});
// set background
var card = document.createElement('div');
card.id = settings.endcard;
card.style.display = 'none';
el.appendChild(card);
settings.getRelatedContent(function(content) {
if (content instanceof Array) {
var related_content_div = document.createElement('div');
related_content_div.id = settings.related;
for (var i = 0; i < content.length; i++) {
related_content_div.appendChild(content[i]);
}
card.appendChild(related_content_div);
}
else {
throw new TypeError("options.getRelatedContent must return an array");
}
});
settings.getNextVid(function(next) {
if (typeof next !== "undefined") {
var next_div = document.createElement('div');
var counter = document.createElement('span');
var countdown = document.createElement('div');
counter.id = settings.counter;
countdown.id = settings.countdown;
next_div.id = settings.next;
countdown.innerHTML = settings.countdown_text;
countdown.appendChild(counter);
next_div.appendChild(countdown);
next_div.appendChild(next);
card.appendChild(next_div);
}
});
var counter_started = 0;
player.on('ended', function() {
card.style.display = 'block';
var next = document.getElementById(settings.next);
if (next !== null) {
var href = next.getElementsByTagName("a")[0].href;
var count = settings.count;
counter.innerHTML = count;
var interval = setInterval(function(){
count--;
if (count <= 0) {
clearInterval(interval);
window.location = href;
return;
}
counter.innerHTML = count;
}, 1000);
}
if (counter_started === 0) {
counter_started++;
player.on('playing', function() {
card.style.display = 'none';
clearInterval(interval);
});
}
});
};
vjs.plugin('endcard', endcard);
})(window.videojs);

View File

@@ -403,7 +403,6 @@ nav.sidebar
$loader-bar-width: 100px
$loader-bar-height: 2px
.loader-bar
background-color: $color-background
bottom: 0
content: ''
display: none
@@ -412,10 +411,12 @@ $loader-bar-height: 2px
position: absolute
visibility: hidden
width: 100%
z-index: 20
&:before
animation: none
background-color: $primary
background-image: linear-gradient(to right, $primary-accent, $primary)
content: ''
display: block
height: $loader-bar-height
@@ -453,3 +454,13 @@ $loader-bar-height: 2px
.progress-bar
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")

View File

@@ -1,7 +1,9 @@
$comments-width-max: 710px
.comments-container
max-width: $comments-width-max
position: relative
width: 100%
#comments-reload
text-align: center
@@ -314,9 +316,6 @@ $comments-width-max: 710px
color: $color-success
.comment-reply
&-container
background-color: $color-background
/* Little gravatar icon on the left */
&-avatar
img
@@ -333,7 +332,7 @@ $comments-width-max: 710px
width: 100%
&-field
background-color: $color-background-dark
background-color: $color-background-light
border-radius: 3px
box-shadow: inset 0 0 2px 0 rgba(darken($color-background-dark, 20%), .5)
display: flex
@@ -342,6 +341,7 @@ $comments-width-max: 710px
textarea
+node-details-description
background-color: $color-background-light
border-bottom-right-radius: 0
border-top-right-radius: 0
border: none
@@ -376,7 +376,6 @@ $comments-width-max: 710px
&.filled
textarea
background-color: $color-background-light
border-bottom: thin solid $color-background
&:focus

View File

@@ -30,6 +30,7 @@ $color-primary: #009eff !default
$color-primary-light: hsl(hue($color-primary), 30%, 90%) !default
$color-primary-dark: hsl(hue($color-primary), 80%, 30%) !default
$color-primary-accent: hsl(hue($color-primary), 100%, 50%) !default
$primary-accent: #0bd
$color-secondary: #f42942 !default
$color-secondary-light: hsl(hue($color-secondary), 30%, 90%) !default
@@ -156,3 +157,7 @@ $tooltip-max-width: auto
$tooltip-opacity: 1
$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)

View File

@@ -24,13 +24,16 @@
color: $color-secondary
#notifications-toggle
color: $color-text
cursor: pointer
font-size: 1.5em
position: relative
user-select: none
> i:before
content: '\e815'
font-size: 1.3em
position: relative
top: 2px
&.has-notifications
> i:before

View File

@@ -77,6 +77,8 @@ body.workshops
a
color: $primary
i
+active-gradient
a
align-items: center
@@ -649,9 +651,6 @@ section.node-details-container
width: 100%
max-width: 100%
.node-details-description
+node-details-description
.node-details-meta
> ul
align-items: center
@@ -1776,7 +1775,7 @@ a.learn-more
box-shadow: 0 5px 35px rgba(black, .2)
color: $color-text-dark-primary
position: absolute
top: 0
top: -$project_header-height
left: 0
right: 0
width: 80%
@@ -1804,7 +1803,7 @@ a.learn-more
&.visible
visibility: visible
opacity: 1
top: $project_header-height
top: 0
.overlay-container
.title

View File

@@ -95,7 +95,7 @@ $search-hit-width_grid: 100px
.search-list
width: 30%
.card-deck.card-deck-horizontal
.card-deck.card-deck-vertical
.card .embed-responsive
max-width: 80px

View File

@@ -67,131 +67,6 @@
&:hover
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
padding: 15px

View File

@@ -171,17 +171,25 @@
/* Small but wide: phablets, iPads
** Menu is collapsed, columns stack, no brand */
=media-sm
@media (min-width: #{$screen-tablet}) and (max-width: #{$screen-desktop - 1px})
@include media-breakpoint-up(sm)
@content
/* Tablets portrait.
** Menu is expanded, but columns stack, brand is shown */
=media-md
@media (min-width: #{$screen-desktop})
@include media-breakpoint-up(md)
@content
=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
=media-print
@@ -659,6 +667,9 @@
.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%.
// .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.
@@ -669,3 +680,15 @@
.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)

View File

@@ -15,85 +15,39 @@
@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/card"
@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 _comments
@import _error
@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%
@import _notifications
#blog_post-edit-form
padding: 20px
@@ -166,7 +120,6 @@
margin-bottom: 15px
border-top: thin solid $color-text-dark-hint
.form-group.description,
.form-group.summary,
.form-group.content
@@ -234,64 +187,10 @@
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-edit-container
+container-box
padding: 25px
width: 75%
+media-xs
@@ -306,11 +205,6 @@
+media-lg
width: 100%
.item-picture+.button-back+.button-edit
right: 20px
top: 20px
#blog_post-edit-form
padding: 0
@@ -345,206 +239,3 @@
.form-upload-file-meta
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

View File

@@ -4,24 +4,38 @@
@extend .row
.card
@extend .col-md-3
+media-xs
@extend .col-md-4
+media-sm
flex: 1 0 50%
max-width: 50%
+media-sm
+media-md
flex: 1 0 33%
max-width: 33%
+media-md
+media-lg
flex: 1 0 33%
max-width: 33%
+media-xl
flex: 1 0 25%
max-width: 25%
+media-lg
+media-xxl
flex: 1 0 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
flex-wrap: initial
@@ -29,6 +43,7 @@
@extend .w-100
@extend .flex-row
flex: initial
flex-wrap: wrap
max-width: 100%
.card-img-top
@@ -98,6 +113,7 @@
i
opacity: .2
/* Tiny label for cards. e.g. 'WATCHED' on videos. */
.card-label
background-color: rgba($black, .5)
border-radius: 3px
@@ -105,7 +121,7 @@
display: block
font-size: $font-size-xxs
left: 5px
top: -25px
top: -27px // enough to be above the progress-bar
position: absolute
padding: 1px 5px
z-index: 1

View File

@@ -24,3 +24,21 @@ ul.dropdown-menu
nav .dropdown:hover
ul.dropdown-menu
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

View File

@@ -1,26 +1,41 @@
// Mainly overrides bootstrap jumbotron settings
.jumbotron
@extend .d-flex
@extend .mb-0
@extend .rounded-0
background-size: cover
border-radius: 0
margin-bottom: 0
padding-top: 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.
&.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
&: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
text-shadow: 1px 1px rgba(black, .2), 1px 1px 25px rgba(black, .5)

View File

@@ -2,8 +2,7 @@
.navbar
box-shadow: inset 0 -2px $color-background
.navbar,
nav.sidebar
.nav
border: none
color: $color-text-dark-secondary
padding: 0
@@ -19,29 +18,6 @@ nav.sidebar
margin: 0
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
user-select: none
position: relative
@@ -80,49 +56,72 @@ nav.sidebar
i
+position-center-translate
.dropdown
min-width: 50px // navbar avatar size
.dropdown
.navbar-item
&:hover
box-shadow: none // Remove the blue underline usually on navbar, from dropdown items.
span.fa-stack
position: absolute
top: 50%
left: 50%
transform: translate(-50%, -50%)
ul.dropdown-menu
li
a
white-space: nowrap
ul.dropdown-menu
li
a
white-space: nowrap
.subitem // e.g. "Not Sintel? Log out"
font-size: .8em
text-transform: initial
&:hover
box-shadow: none // removes underline
i
width: 30px
.subitem // e.g. "Not Sintel? Log out"
font-size: .8em
text-transform: initial
&.subscription-status
a, a:hover
color: $white
i
width: 30px
&.none
background-color: $color-danger
&.subscription-status
a, a:hover
color: $white
&.subscriber
background-color: $color-success
&.none
background-color: $color-danger
&.demo
background-color: $color-info
&.subscriber
background-color: $color-success
span.info
display: block
&.demo
background-color: $color-info
span.info
span.renew
display: block
font-size: .9em
span.renew
display: block
font-size: .9em
.nav-link
@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. */
@@ -132,19 +131,36 @@ $nav-secondary-bar-size: -2px
box-shadow: inset 0 $nav-secondary-bar-size 0 0 $color-background
.nav-link
box-shadow: inset 0 $nav-secondary-bar-size 0 0 $color-background
color: $color-text
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.active,
.nav-item.dropdown.show .nav-link
.nav-item.dropdown.show > .nav-link
// 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
color: $primary
color: $primary-accent
&.nav-secondary-vertical
align-items: flex-start
@@ -156,19 +172,30 @@ $nav-secondary-bar-size: -2px
// Blue bar on the side.
.nav-link
box-shadow: inset 0 -1px 0 0 $color-background, inset -1px 0 0 0 $color-background
&:hover,
&.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
color: $color-text-dark-secondary
@extend .pr-5
box-shadow: none
&:hover
color: $body-color
&.nav-see-more
color: $primary
i, span
+active-gradient
.navbar-overlay
+media-lg
@@ -188,15 +215,6 @@ $nav-secondary-bar-size: -2px
background-color: $color-background-nav
text-shadow: none
.navbar-brand
color: inherit
padding: 0 0 0 3px
position: relative
top: -2px
&:hover
color: $primary
nav.navbar
.navbar-collapse
> ul > li > .navbar-item

View File

@@ -1365,28 +1365,3 @@ video::-webkit-media-text-track-display
position: absolute
bottom: 3em
left: 0.5em
#player-endcard
background-color: rgba($black, .5)
bottom: 0
font-size: 1.5em
left: 0
position: absolute
right: 0
top: 0
z-index: 1
#related-content,
.end-card-content
@extend .align-items-center
@extend .d-flex
@extend .flex-column
@extend .h-100
@extend .justify-content-center
.btn-video-overlay
border: thin solid $white
&:hover
background-color: rgba($white, .5)

View File

@@ -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

View File

@@ -43,7 +43,6 @@ html(lang="en")
| {% 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/base.css') }}", rel="stylesheet")
| {% if title == 'blog' %}
link(href="{{ url_for('static_pillar', filename='assets/css/blog.css') }}", rel="stylesheet")
| {% else %}

View File

@@ -1,10 +1,11 @@
| {% if current_user.is_authenticated %}
li.nav-notifications
a.navbar-item#notifications-toggle.px-0(
title="Notifications",
data-toggle="tooltip",
data-placement="bottom")
li.nav-notifications.nav-item
a.nav-link.px-2(
id="notifications-toggle",
title="Notifications",
data-toggle="tooltip",
data-placement="bottom")
i.pi-notifications-none.nav-notifications-icon
span#notifications-count
span

View File

@@ -12,25 +12,6 @@ li.dropdown
ul.dropdown-menu.dropdown-menu-right
| {% if not current_user.has_role('protected') %}
| {% 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
a.navbar-item.px-2(

View File

@@ -6,7 +6,7 @@
// #}
mixin jumbotron(title, text, image, url)
if url
a.jumbotron.jumbotron-overlay.text-white(
a.jumbotron.text-white(
style='background-image: url(' + image + ');',
href=url)&attributes(attributes)
.container
@@ -19,7 +19,7 @@ mixin jumbotron(title, text, image, url)
.lead
=text
else
.jumbotron.jumbotron-overlay.text-white(style='background-image: url(' + image + ');')&attributes(attributes)
.jumbotron.text-white(style='background-image: url(' + image + ');')&attributes(attributes)
.container
.row
.col-md-9
@@ -35,8 +35,8 @@ mixin jumbotron(title, text, image, url)
mixin nav-secondary(title)
ul.nav.nav-secondary&attributes(attributes)
if title
li.font-weight-bold.px-2
=title
li.nav-item
span.nav-title.nav-link.font-weight-bold.pointer-events-none= title
if block
block
@@ -48,8 +48,8 @@ mixin nav-secondary-link()
a.nav-link&attributes(attributes)
block
mixin card-deck()
.card-deck.card-padless.card-deck-responsive()&attributes(attributes)
mixin card-deck(max_columns)
.card-deck.card-padless.card-deck-responsive(class="card-" + max_columns + "-columns")&attributes(attributes)
if block
block
else

View File

@@ -91,32 +91,9 @@ script(type="text/javascript").
'fetch_progress_url': fetch_progress_url,
});
this.endcard({
getRelatedContent: getRelatedContent
});
{% endif %}
});
function getRelatedContent(callback) {
var el = document.createElement('div');
el.className = 'end-card-content';
el.innerHTML = '<h4><button class="js-video-play my-3 px-5 btn btn-video-overlay"><i class="pi-play"></i> Play Again</button></h4>';
el.innerHTML += '<button class="js-video-loop px-4 btn btn-sm btn-video-overlay"><i class="pi-back"></i> Loop</button>';
setTimeout(function(){
callback([el])
}, 0);
}
$('body').on('click', '.js-video-play', function(){
videoPlayer.play();
});
$('body').on('click', '.js-video-loop', function(){
videoPlayerToggleLoop(videoPlayer, videoPlayerLoopButton);
videoPlayer.play();
});
// Generic utility to add-buttons to the player.
function addVideoPlayerButton(data) {

View File

@@ -1,8 +1,11 @@
| {% extends 'nodes/custom/blog/index.html' %}
| {% import 'nodes/custom/blog/_macros.html' as blogmacros %}
| {% block project_context %}
#blog_container
#blog_index-container.expand-image-links
| {{ blogmacros.render_archive(project, posts, posts_meta) }}
| {% endblock project_context%}
| {% block body %}
.container
.pt-5.pb-2
h2.text-uppercase.font-weight-bold.text-center
| {{ project.name }} Blog Archive
| {{ blogmacros.render_archive(project, posts, posts_meta) }}
| {% endblock body %}

View File

@@ -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 %}

View File

@@ -1,55 +1,40 @@
| {% extends 'projects/view.html' %}
| {% extends 'layout.html' %}
| {% import 'nodes/custom/blog/_macros.html' as blogmacros %}
| {% from 'projects/_macros.html' import render_secondary_navigation %}
| {% set title = 'blog' %}
| {% block page_title %}Blog{% endblock%}
| {% block css %}
| {{ super() }}
link(href="{{ url_for('static_pillar', filename='assets/css/blog.css') }}", rel="stylesheet")
| {% endblock %}
| {% block navigation_tabs %}
| {{ render_secondary_navigation(project, navigation_links, title) }}
| {% endblock navigation_tabs %}
| {% block project_context %}
| {{ blogmacros.render_blog_index(project, posts, can_create_blog_posts, api, more_posts_available, posts_meta) }}
| {% 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 %}
| {% block body %}
| {{ blogmacros.render_blog_index(node, project, posts, can_create_blog_posts, api, more_posts_available, posts_meta, pages=pages) }}
| {% endblock %}
| {% block footer_scripts %}
include ../_scripts
script.
/* UI Stuff */
var project_container = document.getElementById('project-container');
hopToTop(); // Display jump to top button
$(window).on("load resize",function(){
containerResizeY($(window).height());
/* 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();
if ($(window).width() > 480) {
project_container.style.height = (window.innerHeight - project_container.offsetTop) + "px";
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;
}
});

View File

@@ -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 %}

View File

@@ -5,7 +5,7 @@
class="{% if is_reply %}is-reply{% else %}is-first{% endif %}")
.comment-avatar
img(src="{{ comment._user.email | gravatar }}")
img(src="{{ comment._user.email | gravatar }}", alt="{{ comment._user.full_name }}")
.comment-content
.comment-body

View File

@@ -23,60 +23,8 @@ include ../../../mixins/components
i.pi-list
+card-deck(class="px-2")
| {% 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 #}
| {% for child in children %}
| {{ asset_list_item(child, current_user) }}
| {% endfor %}
| {% else %}
.list-node-children-container
@@ -115,14 +63,12 @@ include ../../../mixins/components
// Browse type: icon or list
function projectBrowseTypeIcon() {
$(".list-node-children-item.browse-list").hide();
$(".list-node-children-item.browse-icon").show();
$(".card-deck").removeClass('card-deck-vertical');
$(".js-btn-browsetoggle").html('<i class="pi-list"></i> List View');
};
function projectBrowseTypeList() {
$(".list-node-children-item.browse-list").show();
$(".list-node-children-item.browse-icon").hide();
$(".card-deck").addClass('card-deck-vertical');
$(".js-btn-browsetoggle").html('<i class="pi-layout"></i> Grid View');
};

View File

@@ -1,28 +1,28 @@
| {% extends 'projects/landing.html' %}
include ../../../mixins/components
| {% block body %}
| {% if node.picture %}
header
img.header(src="{{ node.picture.thumbnail('h', api=api) }}")
| {% endif %}
| {% block navbar_secondary %}
| {{ super() }}
| {% endblock navbar_secondary %}
#node-container
#node-overlay
.expand-image-links.imgs-fluid
| {% if node.picture %}
+jumbotron(
"{{ node.name }}",
"{{ node._created | pretty_date }}{% if node.user.full_name %} · {{ node.user.full_name }}{% endif %}",
"{{ node.picture.thumbnail('h', api=api) }}",
"{{ node.url }}")
| {% endif %}
.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
| {% if node.description %}
| {{ node | markdowned('description') }}
| {% endif %}
small.text-muted
span(title="created {{ node._created | pretty_date }}") Updated {{ node._updated | pretty_date }}
small.text-muted
span(title="created {{ node._created | pretty_date }}") Updated {{ node._updated | pretty_date }}
include ../_scripts

View File

@@ -122,41 +122,38 @@ script(type="text/javascript").
.toLowerCase();
};
var convert = new Markdown.getSanitizingConverter().makeHtml;
/* Build the markdown preview when typing in textarea */
$(function() {
var $contentField = $('.form-group.description textarea'),
$contentPreview = $('<div class="node-edit-form-md-preview" />').insertAfter($contentField);
var $textarea = $('.form-group.content textarea'),
$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);
function parseDescriptionContent(content) {
$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 delay = (function(){
var timer = 0;
return function(callback, ms){
clearTimeout (timer);
timer = setTimeout(callback, ms);
};
})();
var options = {
callback: parseDescriptionContent,
wait: 750,
highlight: false,
allowSubmit: false,
captureLength: 2
}
$textarea.keyup(function() {
/* 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');
$contentField.typeWatch(options);
});
$(function() {

View File

@@ -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 %}

View File

@@ -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

View File

@@ -18,11 +18,6 @@ meta(property="og:image", content="{{ node.picture.thumbnail('l', api=api) }}")
| {% 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' %}
| {% block body %}

View File

@@ -68,7 +68,7 @@ script.
#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-error

View File

@@ -25,12 +25,11 @@
| {{ node | markdowned('description') }}
| {% endif %}
| {# 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
| {% if node.properties.license_type %}
li
li.px-2
a.node-details-license(
href="https://creativecommons.org/licenses/",
target="_blank",
@@ -41,15 +40,15 @@
| {% endif %}
| {% 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 }}
| {% endif %}
li(title="Author")
li.px-2(title="Author")
| {{ node.user.full_name }}
| {{ node.user.badges.html|safe }}
li(
li.px-2(
title="Created {{ node._created }} (updated {{ node._updated | pretty_date_time }})")
| {{ node._created | pretty_date }}
@@ -60,14 +59,12 @@
| Shared
| {% endif %}
li.left-side
li.ml-auto
| {% if node.file %}
li(title="File size")
li.px-2(title="File size")
| {{ node.file.length | filesizeformat }}
li.js-type(title="File format")
li.px-2.js-type(title="File format")
| {{ node.file.content_type }}
| {% endif %}
@@ -95,11 +92,11 @@
| {% endblock node_download %}
| {% elif current_user.has_cap('can-renew-subscription') %}
a.btn.btn-success(
a.btn.btn-outline-primary(
title="Renew your subscription to download",
target="_blank",
href="/renew")
i.pi-heart
i.pi-heart.pr-2
| Renew Subscription
| {% elif current_user.is_authenticated %}
@@ -116,12 +113,32 @@
| {% endif %}
| {% 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 %}
#comments-embed
.comments-list-loading
i.pi-spin
| {% endblock node_comments %}
| {% if node.properties.tags %}
.col-md-4.d-none.d-lg-block
script(src="{{ url_for('static_cloud', filename='assets/js/tagged_assets.min.js') }}")
script.
$(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' %}

View File

@@ -1,41 +1,50 @@
| {% macro render_secondary_navigation(project, pages=None) %}
nav.navbar-secondary
nav.collapse.navbar-collapse
ul.navbar-nav.navbar-right
li
a.navbar-item(
href="{{ url_for('projects.view', project_url=project.url) }}",
title="{{ project.name }} Homepage")
span
b {{ project.name }}
li
a.navbar-item(
href="{{ url_for('main.project_blog', project_url=project.url) }}",
title="Project Blog",
class="{% if category == 'blog' %}active{% endif %}")
span Blog
| {% if pages %}
| {% for p in pages %}
li
a.navbar-item(
href="{{ url_for('projects.view_node', project_url=project.url, node_id=p._id) }}",
title="{{ p.name }}",
class="{% if category == 'page' %}active{% endif %}")
span {{ p.name }}
| {% endfor %}
| {% endif %}
| {% if project.nodes_featured %}
| {# In some cases featured_nodes might might be embedded #}
| {% if '_id' in project.nodes_featured[0] %}
| {% set featured_node_id=project.nodes_featured[0]._id %}
| {% else %}
| {% set featured_node_id=project.nodes_featured[0] %}
| {% endif %}
li
a.navbar-item(
href="{{ url_for('projects.view_node', project_url=project.url, node_id=featured_node_id) }}",
title="Explore {{ project.name }}",
class="{% if category == 'blog' %}active{% endif %}")
span Explore
| {% endif %}
include ../mixins/components
| {% macro render_secondary_navigation(project, navigation_links, title) %}
| {% if project.category == 'course' %}
| {% set category_url = url_for('cloud.courses') %}
| {% elif project.category == 'workshop' %}
| {% set category_url = url_for('cloud.workshops') %}
| {% elif project.category == 'film' %}
| {% set category_url = url_for('cloud.open_projects') %}
| {% else %}
| {% set category_url = url_for('main.homepage') %}
| {% endif %}
+nav-secondary()
| {% if project.url != 'blender-cloud' %}
| {% if not project.is_private %}
li.text-capitalize
a.nav-link.text-muted.px-0(href="{{ category_url }}")
span {{ project.category }}
li.px-1
i.pi-angle-right
| {% endif %}
+nav-secondary-link(
class="px-1 font-weight-bold",
href="{{url_for('projects.view', project_url=project.url, _external=True)}}")
span {{ project.name }}
| {% endif %}
| {% for link in navigation_links %}
+nav-secondary-link(href="{{ link['url'] }}")
| {{ link['label'] }}
| {% endfor %}
| {% if project.nodes_featured %}
| {# In some cases featured_nodes might might be embedded #}
| {% if '_id' in project.nodes_featured[0] %}
| {% set featured_node_id=project.nodes_featured[0]._id %}
| {% 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 %}

View File

@@ -29,7 +29,7 @@
.container-fluid
.row
.col-md-12
h5.pl-2.mb-0 Project Overview
h5.pl-2.mb-0.pt-3 Project Overview
#node-edit-container
form(
@@ -122,7 +122,6 @@ script(type="text/javascript").
$('.project-mode-edit').displayAs('inline-block');
ProjectUtils.setProjectAttributes({projectId: "{{project._id}}", isProject: true, nodeId: ''});
var convert = new Markdown.getSanitizingConverter().makeHtml;
$('.button-save').on('click', function(e){
e.preventDefault();
@@ -136,36 +135,36 @@ script(type="text/javascript").
/* Build the markdown preview when typing in textarea */
$(function() {
var $textarea = $('.form-group.description textarea'),
$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);
var $contentField = $('.form-group.description textarea'),
$contentPreview = $('<div class="node-edit-form-md-preview" />').insertAfter($contentField);
$loader.hide();
function parseDescriptionContent(content) {
// Delay function to not start converting heavy posts immediately
var delay = (function(){
var timer = 0;
return function(callback, ms){
clearTimeout (timer);
timer = setTimeout(callback, ms);
};
})();
$.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');
});
}
$textarea.keyup(function() {
/* If there's an iframe (YouTube embed), delay markdown convert 1.5s */
if (/iframe/i.test($textarea.val())) {
$loader.show();
var options = {
callback: parseDescriptionContent,
wait: 750,
highlight: false,
allowSubmit: false,
captureLength: 2
}
delay(function(){
// Convert markdown
$preview.html(convert($textarea.val()));
$loader.hide();
}, 1500 );
} else {
// Convert markdown
$preview.html(convert($textarea.val()));
};
}).trigger('keyup');
$contentField.typeWatch(options);
$('input, textarea').keypress(function () {
// Unused: save status of the page as 'edited'

View File

@@ -45,7 +45,7 @@ include ../mixins/components
#project_nav
#project_nav-container
// TODO - make list a macro
#project_tree.edit.bg-white
#project_tree.edit.bg-light
+nav-secondary()(class="nav-secondary-vertical")
+nav-secondary-link(
class="{% if title == 'edit' %}active{% endif %}",

View File

@@ -1,36 +1,34 @@
| {% extends 'layout.html' %}
include ../../mixins/components
//- Don't extend this base file directly. Instead, extend page.html so that Pillar extensions
//- can provide overrides.
| {% block body %}
.container
#settings.d-flex.py-4.flex-xs-column
#settings-sidebar
.container.py-4
.row
.col-md-3
| {% block settings_sidebar %}
.settings-header
.settings-title Settings
.settings-content
ul
| {% block settings_sidebar_menu %}
a(class="{% if title == 'profile' %}active{% endif %}",
href="{{ url_for('settings.profile') }}")
li
i.pi-vcard
| Profile
| {% endblock settings_sidebar_menu %}
| {% block settings_sidebar_menu_bottom %}
a(class="{% if title == 'roles' %}active{% endif %}",
href="{{ url_for('settings.roles') }}")
li
i.pi-cog
| Roles &amp; Capabilities
| {% endblock settings_sidebar_menu_bottom %}
+nav-secondary('Settings')(class="nav-secondary-vertical bg-light")
| {% block settings_sidebar_menu %}
+nav-secondary-link(
class="{% if title == 'profile' %}active{% endif %} border-top",
href="{{ url_for('settings.profile') }}")
i.pr-3.pi-vcard
span Profile
| {% endblock settings_sidebar_menu %}
| {% block settings_sidebar_menu_bottom %}
+nav-secondary-link(
class="{% if title == 'roles' %}active{% endif %}",
href="{{ url_for('settings.roles') }}")
i.pr-3.pi-cog
span Roles &amp; Capabilities
| {% endblock settings_sidebar_menu_bottom %}
| {% endblock %}
#settings-container
.settings-header
.settings-title {% block settings_page_title %}{{ _("Title not set") }}{% endblock %}
.col-md-9
h3.py-1 {% 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 %}

View File

@@ -21,7 +21,7 @@ style.
| {% block settings_page_content %}
.settings-form
form#settings-form(method='POST', action="{{url_for('settings.profile')}}")
.left
.pb-3
.form-group
| {{ form.username.label }}
| {{ form.username(size=20, class='form-control') }}
@@ -45,14 +45,13 @@ style.
| {{ current_user.badges_html|safe }}
p.hint-text Note that updates to these badges may take a few minutes to be visible here.
| {% endif %}
.right
.settings-avatar
a(href="https://gravatar.com/")
img(src="{{ current_user.gravatar }}")
span {{ _("Change Gravatar") }}
.py-3
a(href="https://gravatar.com/")
img.rounded-circle(src="{{ current_user.gravatar }}")
span.p-3 {{ _("Change Gravatar") }}
.buttons
button.btn.btn-outline-success.button-submit(type='submit')
i.pi-check
.py-3
button.btn.btn-outline-success.px-5.button-submit(type='submit')
i.pi-check.pr-2
| {{ _("Save Changes") }}
| {% endblock %}

View File

@@ -479,61 +479,65 @@ class TextureSortFilesTest(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
self.fake_now = utcnow()
def test_tagged_nodes_api(self):
from datetime import timedelta
pid, _ = self.ensure_project_exists()
file_id, _ = self.ensure_file_exists()
uid = self.create_user()
now = utcnow()
base_node = {
'name': 'Just a node name',
'project': pid,
'project': self.pid,
'description': '',
'node_type': 'asset',
'user': uid,
'user': self.uid,
}
base_props = {'status': 'published',
'file': file_id,
'file': self.file_id,
'content_type': 'video',
'order': 0}
# No tags, should never be returned.
self.create_node({
'_created': now,
'_created': self.fake_now,
'properties': base_props,
**base_node})
# Empty tag list, should never be returned.
self.create_node({
'_created': now + timedelta(seconds=1),
'_created': self.fake_now + timedelta(seconds=1),
'properties': {'tags': [], **base_props},
**base_node})
# Empty string as tag, should never be returned.
self.create_node({
'_created': now + timedelta(seconds=1),
'_created': self.fake_now + timedelta(seconds=1),
'properties': {'tags': [''], **base_props},
**base_node})
nid_single_tag = self.create_node({
'_created': now + timedelta(seconds=2),
'_created': self.fake_now + timedelta(seconds=2),
# 'एनिमेशन' is 'animation' in Hindi.
'properties': {'tags': ['एनिमेशन'], **base_props},
**base_node,
})
nid_double_tag = self.create_node({
'_created': now + timedelta(hours=3),
'_created': self.fake_now + timedelta(hours=3),
'properties': {'tags': ['एनिमेशन', 'rigging'], **base_props},
**base_node,
})
nid_other_tag = self.create_node({
'_deleted': False,
'_created': now + timedelta(days=4),
'_created': self.fake_now + timedelta(days=4),
'properties': {'tags': ['producción'], **base_props},
**base_node,
})
# Matching tag but deleted node, should never be returned.
self.create_node({
'_created': now + timedelta(seconds=1),
'_created': self.fake_now + timedelta(seconds=1),
'_deleted': True,
'properties': {'tags': ['एनिमेशन'], **base_props},
**base_node})
@@ -556,3 +560,76 @@ class TaggedNodesTest(AbstractPillarTest):
with self.app.app_context():
invalid_url = flask.url_for('nodes_api.tagged', tag='')
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)

View File

@@ -187,37 +187,48 @@ class AttachmentTest(AbstractPillarTest):
],
'filename': 'cute_kitten.jpg',
})
node_doc = {'properties': {
'attachments': {
'img': {'oid': oid},
}
node_properties = {'attachments': {
'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
# API (which is what the shortcode rendering is doing) will change its
# link URL.
db_file = self.get(f'/api/files/{oid}').get_json()
link = db_file['variations'][0]['link']
with self.app.test_request_context():
self_linked = f'<a class="expand-image-links" href="{link}">' \
f'<img src="{link}" alt="cute_kitten.jpg"/></a>'
self.assertEqual(
self_linked,
render('{attachment img link}', context=node_doc).strip()
)
self.assertEqual(
self_linked,
render('{attachment img link=self}', context=node_doc).strip()
)
self.assertEqual(
f'<img src="{link}" alt="cute_kitten.jpg"/>',
render('{attachment img}', context=node_doc).strip()
)
def do_render(context, link):
"""Utility to run attachment rendering in different contexts."""
with self.app.test_request_context():
self_linked = f'<a class="expand-image-links" href="{link}">' \
f'<img src="{link}" alt="cute_kitten.jpg"/></a>'
self.assertEqual(
self_linked,
render('{attachment img link}', context=context).strip()
)
self.assertEqual(
self_linked,
render('{attachment img link=self}', context=context).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'
self.assertEqual(
f'<a href="{tag_link}" target="_blank">'
f'<img src="{link}" alt="cute_kitten.jpg"/></a>',
render('{attachment img link=%r}' % tag_link, context=node_doc).strip()
)
tag_link = 'https://i.imgur.com/FmbuPNe.jpg'
self.assertEqual(
f'<a href="{tag_link}" target="_blank">'
f'<img src="{link}" alt="cute_kitten.jpg"/></a>',
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)