15 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
23 changed files with 602 additions and 753 deletions

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):
@@ -226,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
@@ -512,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

@@ -455,3 +455,12 @@ $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

@@ -3,6 +3,7 @@ $comments-width-max: 710px
.comments-container
max-width: $comments-width-max
position: relative
width: 100%
#comments-reload
text-align: center

View File

@@ -158,4 +158,6 @@ $tooltip-opacity: 1
$nav-link-height: 37px
$navbar-padding-x: 0
$navbar-padding-y: 0
$navbar-padding-y: 0
$grid-breakpoints: (xs: 0,sm: 576px,md: 768px,lg: 1100px,xl: 1500px, xxl: 1800px)

View File

@@ -651,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
@@ -1778,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%
@@ -1806,7 +1803,7 @@ a.learn-more
&.visible
visibility: visible
opacity: 1
top: $project_header-height
top: 0
.overlay-container
.title

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

View File

@@ -22,6 +22,7 @@
@import "../../node_modules/bootstrap/scss/nav"
@import "../../node_modules/bootstrap/scss/navbar"
@import "../../node_modules/bootstrap/scss/card"
@import "../../node_modules/bootstrap/scss/jumbotron"
@import "../../node_modules/bootstrap/scss/media"
@import "../../node_modules/bootstrap/scss/close"
@@ -34,6 +35,7 @@
@import "apps_base"
@import "components/base"
@import "components/card"
@import "components/jumbotron"
@import "components/navbar"
@import "components/dropdown"
@@ -47,14 +49,6 @@
@import _comments
@import _notifications
#blog_container
+media-xs
flex-direction: column
padding-top: 0
video
max-width: 100%
#blog_post-edit-form
padding: 20px
@@ -126,7 +120,6 @@
margin-bottom: 15px
border-top: thin solid $color-text-dark-hint
.form-group.description,
.form-group.summary,
.form-group.content
@@ -194,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
@@ -266,11 +205,6 @@
+media-lg
width: 100%
.item-picture+.button-back+.button-edit
right: 20px
top: 20px
#blog_post-edit-form
padding: 0
@@ -305,180 +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

View File

@@ -4,23 +4,37 @@
@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-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

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

@@ -57,8 +57,6 @@
+position-center-translate
.dropdown
min-width: 50px // navbar avatar size
.navbar-item
&:hover
box-shadow: none // Remove the blue underline usually on navbar, from dropdown items.
@@ -195,7 +193,6 @@ $nav-secondary-bar-size: -2px
&.nav-see-more
color: $primary
font-size: $font-size-xxs
i, span
+active-gradient

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

@@ -3,9 +3,9 @@
| {% block body %}
.container
.pt-4
h2.text-uppercase.font-weight-bold
| Blog Archive
.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

@@ -11,11 +11,7 @@
| {% endblock navigation_tabs %}
| {% block body %}
| {% if node %}
| {{ blogmacros.render_blog_post(node, project=project) }}
| {% else %}
| {{ blogmacros.render_blog_index(project, posts, can_create_blog_posts, api, more_posts_available, posts_meta, pages=pages) }}
| {% endif %}
| {{ blogmacros.render_blog_index(node, project, posts, can_create_blog_posts, api, more_posts_available, posts_meta, pages=pages) }}
| {% endblock %}
| {% block footer_scripts %}

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

@@ -1,26 +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 %}
.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-container
#node-overlay
.container.pb-5
.row
.col-8.mx-auto
h2.pt-5.pb-3.text-center {{node.name}}
.node-details-container.page.expand-image-links.imgs-fluid
| {% if node.description %}
.node-details-description
| {{ node | markdowned('description') }}
| {% endif %}
h2.pt-3.text-center {{node.name}}
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

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

@@ -14,11 +14,13 @@ include ../mixins/components
+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",
@@ -26,7 +28,12 @@ include ../mixins/components
span {{ project.name }}
| {% endif %}
| {% if project.nodes_featured and (title !='project') %}
| {% 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 %}
@@ -40,9 +47,4 @@ include ../mixins/components
span Explore
| {% endif %}
| {% for link in navigation_links %}
+nav-secondary-link(href="{{ link['url'] }}")
| {{ link['label'] }}
| {% endfor %}
| {% 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

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