Merge branch 'master' into production

This commit is contained in:
Francesco Siddi 2018-09-18 15:50:55 +02:00
commit 31244a89e5
150 changed files with 7271 additions and 12952 deletions

1
.gitignore vendored
View File

@ -27,6 +27,7 @@ profile.stats
pillar/web/static/assets/css/*.css
pillar/web/static/assets/js/*.min.js
pillar/web/static/assets/js/vendor/video.min.js
pillar/web/static/storage/
pillar/web/static/uploads/
pillar/web/templates/

View File

@ -12,15 +12,16 @@ var pug = require('gulp-pug');
var rename = require('gulp-rename');
var sass = require('gulp-sass');
var sourcemaps = require('gulp-sourcemaps');
var uglify = require('gulp-uglify');
var uglify = require('gulp-uglify-es').default;
var enabled = {
uglify: argv.production,
maps: argv.production,
maps: !argv.production,
failCheck: !argv.production,
prettyPug: !argv.production,
cachify: !argv.production,
cleanup: argv.production,
chmod: argv.production,
};
var destination = {
@ -29,6 +30,11 @@ var destination = {
js: 'pillar/web/static/assets/js',
}
var source = {
bootstrap: 'node_modules/bootstrap/',
jquery: 'node_modules/jquery/',
popper: 'node_modules/popper.js/'
}
/* CSS */
gulp.task('styles', function() {
@ -67,36 +73,52 @@ gulp.task('scripts', function() {
.pipe(gulpif(enabled.uglify, uglify()))
.pipe(rename({suffix: '.min'}))
.pipe(gulpif(enabled.maps, sourcemaps.write(".")))
.pipe(chmod(644))
.pipe(gulpif(enabled.chmod, chmod(644)))
.pipe(gulp.dest(destination.js))
.pipe(gulpif(argv.livereload, livereload()));
});
/* Collection of scripts in src/scripts/tutti/ to merge into tutti.min.js */
/* Since it's always loaded, it's only for functions that we want site-wide */
/* Collection of scripts in src/scripts/tutti/ to merge into tutti.min.js
* Since it's always loaded, it's only for functions that we want site-wide.
* It also includes jQuery and Bootstrap (and its dependency popper), since
* the site doesn't work without it anyway.*/
gulp.task('scripts_concat_tutti', function() {
gulp.src('src/scripts/tutti/**/*.js')
toUglify = [
source.jquery + 'dist/jquery.min.js',
source.popper + 'dist/umd/popper.min.js',
source.bootstrap + 'js/dist/index.js',
source.bootstrap + 'js/dist/util.js',
source.bootstrap + 'js/dist/alert.js',
source.bootstrap + 'js/dist/collapse.js',
source.bootstrap + 'js/dist/dropdown.js',
source.bootstrap + 'js/dist/tooltip.js',
'src/scripts/tutti/**/*.js'
];
gulp.src(toUglify)
.pipe(gulpif(enabled.failCheck, plumber()))
.pipe(gulpif(enabled.maps, sourcemaps.init()))
.pipe(concat("tutti.min.js"))
.pipe(gulpif(enabled.uglify, uglify()))
.pipe(gulpif(enabled.maps, sourcemaps.write(".")))
.pipe(chmod(644))
.pipe(gulpif(enabled.chmod, chmod(644)))
.pipe(gulp.dest(destination.js))
.pipe(gulpif(argv.livereload, livereload()));
});
gulp.task('scripts_concat_markdown', function() {
gulp.src('src/scripts/markdown/**/*.js')
.pipe(gulpif(enabled.failCheck, plumber()))
.pipe(gulpif(enabled.maps, sourcemaps.init()))
.pipe(concat("markdown.min.js"))
.pipe(gulpif(enabled.uglify, uglify()))
.pipe(gulpif(enabled.maps, sourcemaps.write(".")))
.pipe(chmod(644))
.pipe(gulp.dest(destination.js))
.pipe(gulpif(argv.livereload, livereload()));
/* Simply move these vendor scripts from node_modules. */
gulp.task('scripts_move_vendor', function(done) {
let toMove = [
'node_modules/video.js/dist/video.min.js',
];
gulp.src(toMove)
.pipe(gulp.dest(destination.js + '/vendor/'));
done();
});
@ -111,9 +133,9 @@ gulp.task('watch',function() {
gulp.watch('src/templates/**/*.pug',['templates']);
gulp.watch('src/scripts/*.js',['scripts']);
gulp.watch('src/scripts/tutti/**/*.js',['scripts_concat_tutti']);
gulp.watch('src/scripts/markdown/**/*.js',['scripts_concat_markdown']);
});
// Erases all generated files in output directories.
gulp.task('cleanup', function() {
var paths = [];
@ -136,5 +158,5 @@ gulp.task('default', tasks.concat([
'templates',
'scripts',
'scripts_concat_tutti',
'scripts_concat_markdown',
'scripts_move_vendor',
]));

3597
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,26 +1,32 @@
{
"name": "pillar",
"license": "GPL-2.0+",
"author": "Blender Institute",
"repository": {
"type": "git",
"url": "https://github.com/armadillica/pillar.git"
},
"devDependencies": {
"gulp": "~3.9.1",
"gulp-autoprefixer": "~2.3.1",
"gulp-cached": "~1.1.0",
"gulp-chmod": "~1.3.0",
"gulp-concat": "~2.6.0",
"gulp-if": "^2.0.1",
"gulp-git": "~2.4.2",
"gulp-livereload": "~3.8.1",
"gulp-plumber": "~1.1.0",
"gulp-pug": "~3.2.0",
"gulp-rename": "~1.2.2",
"gulp-sass": "~2.3.1",
"gulp-sourcemaps": "~1.6.0",
"gulp-uglify": "~1.5.3",
"minimist": "^1.2.0"
}
"name": "pillar",
"license": "GPL-2.0+",
"author": "Blender Institute",
"repository": {
"type": "git",
"url": "git://git.blender.org/pillar.git"
},
"devDependencies": {
"gulp": "^3.9.1",
"gulp-autoprefixer": "^6.0.0",
"gulp-cached": "^1.1.1",
"gulp-chmod": "^2.0.0",
"gulp-concat": "^2.6.1",
"gulp-if": "^2.0.2",
"gulp-git": "^2.8.0",
"gulp-livereload": "^4.0.0",
"gulp-plumber": "^1.2.0",
"gulp-pug": "^4.0.1",
"gulp-rename": "^1.4.0",
"gulp-sass": "^4.0.1",
"gulp-sourcemaps": "^2.6.4",
"gulp-uglify-es": "^1.0.4",
"minimist": "^1.2.0"
},
"dependencies": {
"bootstrap": "^4.1.3",
"jquery": "^3.3.1",
"popper.js": "^1.14.4",
"video.js": "^7.2.2"
}
}

View File

@ -140,8 +140,6 @@ class PillarServer(BlinkerCompatibleEve):
self.org_manager = pillar.api.organizations.OrgManager()
self.before_first_request(self.setup_db_indices)
# Make CSRF protection available to the application. By default it is
# disabled on all endpoints. More info at WTF_CSRF_CHECK_DEFAULT in config.py
self.csrf = CSRFProtect(self)
@ -479,10 +477,11 @@ class PillarServer(BlinkerCompatibleEve):
# Pillar-defined Celery task modules:
celery_task_modules = [
'pillar.celery.tasks',
'pillar.celery.search_index_tasks',
'pillar.celery.file_link_tasks',
'pillar.celery.badges',
'pillar.celery.email_tasks',
'pillar.celery.file_link_tasks',
'pillar.celery.search_index_tasks',
'pillar.celery.tasks',
]
# Allow Pillar extensions from defining their own Celery tasks.
@ -704,6 +703,8 @@ class PillarServer(BlinkerCompatibleEve):
def finish_startup(self):
self.log.info('Using MongoDB database %r', self.config['MONGO_DBNAME'])
with self.app_context():
self.setup_db_indices()
self._config_celery()
api.setup_app(self)
@ -760,6 +761,8 @@ class PillarServer(BlinkerCompatibleEve):
coll.create_index([('properties.status', pymongo.ASCENDING),
('node_type', pymongo.ASCENDING),
('_created', pymongo.DESCENDING)])
# Used for asset tags
coll.create_index([('properties.tags', pymongo.ASCENDING)])
coll = db['projects']
# This index is used for statistics, and for fetching public projects.

View File

@ -220,7 +220,7 @@ def fetch_blenderid_user() -> dict:
my_log = log.getChild('fetch_blenderid_user')
bid_url = '%s/api/user' % current_app.config['BLENDER_ID_ENDPOINT']
bid_url = urljoin(current_app.config['BLENDER_ID_ENDPOINT'], 'api/user')
my_log.debug('Fetching user info from %s', bid_url)
credentials = current_app.config['OAUTH_CREDENTIALS']['blender-id']

View File

@ -123,6 +123,43 @@ users_schema = {
'allow_unknown': True,
},
# Node-specific information for this user.
'nodes': {
'type': 'dict',
'schema': {
# Per watched video info about where the user left off, both in time and in percent.
'view_progress': {
'type': 'dict',
# Keyed by Node ID of the video asset. MongoDB doesn't support using
# ObjectIds as key, so we cast them to string instead.
'keyschema': {'type': 'string'},
'valueschema': {
'type': 'dict',
'schema': {
'progress_in_sec': {'type': 'float', 'min': 0},
'progress_in_percent': {'type': 'integer', 'min': 0, 'max': 100},
# When the progress was last updated, so we can limit this history to
# the last-watched N videos if we want, or show stuff in chrono order.
'last_watched': {'type': 'datetime'},
# True means progress_in_percent = 100, for easy querying
'done': {'type': 'boolean', 'default': False},
},
},
},
},
},
'badges': {
'type': 'dict',
'schema': {
'html': {'type': 'string'}, # HTML fetched from Blender ID.
'expires': {'type': 'datetime'}, # When we should fetch it again.
},
},
# Properties defined by extensions. Extensions should use their name (see the
# PillarExtension.name property) as the key, and are free to use whatever they want as value,
# but we suggest a dict for future extendability.
@ -339,11 +376,11 @@ tokens_schema = {
},
'token': {
'type': 'string',
'required': False,
'required': True,
},
'token_hashed': {
'type': 'string',
'required': True,
'required': False,
},
'expire_time': {
'type': 'datetime',
@ -362,6 +399,13 @@ tokens_schema = {
'type': 'string',
},
},
# OAuth scopes granted to this token.
'oauth_scopes': {
'type': 'list',
'default': [],
'schema': {'type': 'string'},
}
}
files_schema = {

View File

@ -94,17 +94,10 @@ def generate_and_store_token(user_id, days=15, prefix=b'') -> dict:
# Use 'xy' as altargs to prevent + and / characters from appearing.
# We never have to b64decode the string anyway.
token_bytes = prefix + base64.b64encode(random_bits, altchars=b'xy').strip(b'=')
token = token_bytes.decode('ascii')
token = prefix + base64.b64encode(random_bits, altchars=b'xy').strip(b'=')
token_expiry = utcnow() + datetime.timedelta(days=days)
token_data = store_token(user_id, token, token_expiry)
# Include the token in the returned document so that it can be stored client-side,
# in configuration, etc.
token_data['token'] = token
return token_data
return store_token(user_id, token.decode('ascii'), token_expiry)
def hash_password(password: str, salt: typing.Union[str, bytes]) -> str:

View File

@ -1,17 +1,11 @@
import base64
import functools
import logging
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 eve_hooks
from pillar.api.utils import str2id, jsonify
from pillar.api.utils.authorization import check_permissions, require_login
@ -20,40 +14,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):
@ -86,7 +46,68 @@ def share_node(node_id):
else:
return '', 204
return jsonify(short_link_info(short_code), status=status)
return jsonify(eve_hooks.short_link_info(short_code), status=status)
@blueprint.route('/tagged/')
@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()
# 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):
"""Fetch all public nodes with the given tag.
This function is cached, see setup_app().
"""
nodes_coll = current_app.db('nodes')
agg = nodes_coll.aggregate([
{'$match': {'properties.tags': tag,
'_deleted': {'$ne': True}}},
# Only get nodes from public projects. This is done after matching the
# tagged nodes, because most likely nobody else will be able to tag
# nodes anyway.
{'$lookup': {
'from': 'projects',
'localField': 'project',
'foreignField': '_id',
'as': '_project',
}},
{'$match': {'_project.is_private': False}},
# Don't return the entire project for each node.
{'$project': {'_project': False}},
{'$sort': {'_created': -1}}
])
return list(agg)
def generate_and_store_short_code(node):
@ -164,307 +185,35 @@ 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
cached = app.cache.memoize(timeout=300)
_tagged = cached(_tagged)
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 += eve_hooks.before_returning_node
app.on_fetched_resource_nodes += eve_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 += eve_hooks.before_replacing_node
app.on_replace_nodes += eve_hooks.parse_markdown
app.on_replace_nodes += eve_hooks.texture_sort_files
app.on_replace_nodes += eve_hooks.deduct_content_type
app.on_replace_nodes += eve_hooks.node_set_default_picture
app.on_replaced_nodes += eve_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 += eve_hooks.before_inserting_nodes
app.on_insert_nodes += eve_hooks.parse_markdowns
app.on_insert_nodes += eve_hooks.nodes_deduct_content_type
app.on_insert_nodes += eve_hooks.nodes_set_default_picture
app.on_insert_nodes += eve_hooks.textures_sort_files
app.on_inserted_nodes += eve_hooks.after_inserting_nodes
app.on_update_nodes += texture_sort_files
app.on_update_nodes += eve_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 += eve_hooks.before_deleting_node
app.on_deleted_item_nodes += eve_hooks.after_deleting_node
app.register_api_blueprint(blueprint, url_prefix=url_prefix)

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

@ -142,7 +142,7 @@ def after_fetching_user(user):
return
# Remove all fields except public ones.
public_fields = {'full_name', 'username', 'email', 'extension_props_public'}
public_fields = {'full_name', 'username', 'email', 'extension_props_public', 'badges'}
for field in list(user.keys()):
if field not in public_fields:
del user[field]

View File

@ -1,9 +1,11 @@
import logging
from eve.methods.get import get
from flask import Blueprint
from flask import Blueprint, request
import werkzeug.exceptions as wz_exceptions
from pillar.api.utils import jsonify
from pillar import current_app
from pillar.api import utils
from pillar.api.utils.authorization import require_login
from pillar.auth import current_user
@ -15,7 +17,128 @@ blueprint_api = Blueprint('users_api', __name__)
@require_login()
def my_info():
eve_resp, _, _, status, _ = get('users', {'_id': current_user.user_id})
resp = jsonify(eve_resp['_items'][0], status=status)
resp = utils.jsonify(eve_resp['_items'][0], status=status)
return resp
@blueprint_api.route('/video/<video_id>/progress')
@require_login()
def get_video_progress(video_id: str):
"""Return video progress information.
Either a `204 No Content` is returned (no information stored),
or a `200 Ok` with JSON from Eve's 'users' schema, from the key
video.view_progress.<video_id>.
"""
# Validation of the video ID; raises a BadRequest when it's not an ObjectID.
# This isn't strictly necessary, but it makes this function behave symmetrical
# to the set_video_progress() function.
utils.str2id(video_id)
users_coll = current_app.db('users')
user_doc = users_coll.find_one(current_user.user_id, projection={'nodes.view_progress': True})
try:
progress = user_doc['nodes']['view_progress'][video_id]
except KeyError:
return '', 204
if not progress:
return '', 204
return utils.jsonify(progress)
@blueprint_api.route('/video/<video_id>/progress', methods=['POST'])
@require_login()
def set_video_progress(video_id: str):
"""Save progress information about a certain video.
Expected parameters:
- progress_in_sec: float number of seconds
- progress_in_perc: integer percentage of video watched (interval [0-100])
"""
my_log = log.getChild('set_video_progress')
my_log.debug('Setting video progress for user %r video %r', current_user.user_id, video_id)
# Constructing this response requires an active app, and thus can't be done on module load.
no_video_response = utils.jsonify({'_message': 'No such video'}, status=404)
try:
progress_in_sec = float(request.form['progress_in_sec'])
progress_in_perc = int(request.form['progress_in_perc'])
except KeyError as ex:
my_log.debug('Missing POST field in request: %s', ex)
raise wz_exceptions.BadRequest(f'missing a form field')
except ValueError as ex:
my_log.debug('Invalid value for POST field in request: %s', ex)
raise wz_exceptions.BadRequest(f'Invalid value for field: {ex}')
users_coll = current_app.db('users')
nodes_coll = current_app.db('nodes')
# First check whether this is actually an existing video
video_oid = utils.str2id(video_id)
video_doc = nodes_coll.find_one(video_oid, projection={
'node_type': True,
'properties.content_type': True,
'properties.file': True,
})
if not video_doc:
my_log.debug('Node %r not found, unable to set progress for user %r',
video_oid, current_user.user_id)
return no_video_response
try:
is_video = (video_doc['node_type'] == 'asset'
and video_doc['properties']['content_type'] == 'video')
except KeyError:
is_video = False
if not is_video:
my_log.info('Node %r is not a video, unable to set progress for user %r',
video_oid, current_user.user_id)
# There is no video found at this URL, so act as if it doesn't even exist.
return no_video_response
# Compute the progress
percent = min(100, max(0, progress_in_perc))
progress = {
'progress_in_sec': progress_in_sec,
'progress_in_percent': percent,
'last_watched': utils.utcnow(),
}
# After watching a certain percentage of the video, we consider it 'done'
#
# Total Credit start Total Credit Percent
# HH:MM:SS HH:MM:SS sec sec of duration
# Sintel 00:14:48 00:12:24 888 744 83.78%
# Tears of Steel 00:12:14 00:09:49 734 589 80.25%
# Cosmos Laundro 00:12:10 00:10:05 730 605 82.88%
# Agent 327 00:03:51 00:03:26 231 206 89.18%
# Caminandes 3 00:02:30 00:02:18 150 138 92.00%
# Glass Half 00:03:13 00:02:52 193 172 89.12%
# Big Buck Bunny 00:09:56 00:08:11 596 491 82.38%
# Elephants Drea 00:10:54 00:09:25 654 565 86.39%
#
# Median 85.09%
# Average 85.75%
#
# For training videos marking at done at 85% of the video may be a bit
# early, since those probably won't have (long) credits. This is why we
# stick to 90% here.
if percent >= 90:
progress['done'] = True
# Setting each property individually prevents us from overwriting any
# existing {done: true} fields.
updates = {f'nodes.view_progress.{video_id}.{k}': v
for k, v in progress.items()}
result = users_coll.update_one({'_id': current_user.user_id},
{'$set': updates})
if result.matched_count == 0:
my_log.error('Current user %r could not be updated', current_user.user_id)
raise wz_exceptions.InternalServerError('Unable to find logged-in user')
return '', 204

View File

@ -245,4 +245,10 @@ def random_etag() -> str:
def utcnow() -> datetime.datetime:
return datetime.datetime.now(tz=bson.tz_util.utc)
"""Construct timezone-aware 'now' in UTC with millisecond precision."""
now = datetime.datetime.now(tz=bson.tz_util.utc)
# MongoDB stores in millisecond precision, so truncate the microseconds.
# This way the returned datetime can be round-tripped via MongoDB and stay the same.
trunc_now = now.replace(microsecond=now.microsecond - (now.microsecond % 1000))
return trunc_now

View File

@ -13,7 +13,7 @@ import logging
import typing
import bson
from flask import g, current_app
from flask import g, current_app, session
from flask import request
from werkzeug import exceptions as wz_exceptions
@ -103,7 +103,7 @@ def find_user_in_db(user_info: dict, provider='blender-id') -> dict:
return db_user
def validate_token(*, force=False):
def validate_token(*, force=False) -> bool:
"""Validate the token provided in the request and populate the current_user
flask.g object, so that permissions and access to a resource can be defined
from it.
@ -115,7 +115,7 @@ def validate_token(*, force=False):
:returns: True iff the user is logged in with a valid Blender ID token.
"""
from pillar.auth import AnonymousUser
import pillar.auth
# Trust a pre-existing g.current_user
if not force:
@ -133,16 +133,22 @@ def validate_token(*, force=False):
oauth_subclient = ''
else:
# Check the session, the user might be logged in through Flask-Login.
from pillar import auth
token = auth.get_blender_id_oauth_token()
# The user has a logged-in session; trust only if this request passes a CSRF check.
# FIXME(Sybren): we should stop saving the token as 'user_id' in the sesion.
token = session.get('user_id')
if token:
log.debug('skipping token check because current user already has a session')
current_app.csrf.protect()
else:
token = pillar.auth.get_blender_id_oauth_token()
oauth_subclient = None
if not token:
# If no authorization headers are provided, we are getting a request
# from a non logged in user. Proceed accordingly.
log.debug('No authentication headers, so not logged in.')
g.current_user = AnonymousUser()
g.current_user = pillar.auth.AnonymousUser()
return False
return validate_this_token(token, oauth_subclient) is not None
@ -194,7 +200,7 @@ def remove_token(token: str):
tokens_coll = current_app.db('tokens')
token_hashed = hash_auth_token(token)
# TODO: remove matching on unhashed tokens once all tokens have been hashed.
# TODO: remove matching on hashed tokens once all hashed tokens have expired.
lookup = {'$or': [{'token': token}, {'token_hashed': token_hashed}]}
del_res = tokens_coll.delete_many(lookup)
log.debug('Removed token %r, matched %d documents', token, del_res.deleted_count)
@ -206,7 +212,7 @@ def find_token(token, is_subclient_token=False, **extra_filters):
tokens_coll = current_app.db('tokens')
token_hashed = hash_auth_token(token)
# TODO: remove matching on unhashed tokens once all tokens have been hashed.
# TODO: remove matching on hashed tokens once all hashed tokens have expired.
lookup = {'$or': [{'token': token}, {'token_hashed': token_hashed}],
'is_subclient_token': True if is_subclient_token else {'$in': [False, None]},
'expire_time': {"$gt": utcnow()}}
@ -229,8 +235,14 @@ def hash_auth_token(token: str) -> str:
return base64.b64encode(digest).decode('ascii')
def store_token(user_id, token: str, token_expiry, oauth_subclient_id=False,
org_roles: typing.Set[str] = frozenset()):
def store_token(user_id,
token: str,
token_expiry,
oauth_subclient_id=False,
*,
org_roles: typing.Set[str] = frozenset(),
oauth_scopes: typing.Optional[typing.List[str]] = None,
):
"""Stores an authentication token.
:returns: the token document from MongoDB
@ -240,13 +252,15 @@ def store_token(user_id, token: str, token_expiry, oauth_subclient_id=False,
token_data = {
'user': user_id,
'token_hashed': hash_auth_token(token),
'token': token,
'expire_time': token_expiry,
}
if oauth_subclient_id:
token_data['is_subclient_token'] = True
if org_roles:
token_data['org_roles'] = sorted(org_roles)
if oauth_scopes:
token_data['oauth_scopes'] = oauth_scopes
r, _, _, status = current_app.post_internal('tokens', token_data)

View File

@ -1,5 +1,6 @@
import logging
import functools
import typing
from bson import ObjectId
from flask import g
@ -12,8 +13,9 @@ CHECK_PERMISSIONS_IMPLEMENTED_FOR = {'projects', 'nodes', 'flamenco_jobs'}
log = logging.getLogger(__name__)
def check_permissions(collection_name, resource, method, append_allowed_methods=False,
check_node_type=None):
def check_permissions(collection_name: str, resource: dict, method: str,
append_allowed_methods=False,
check_node_type: typing.Optional[str] = None):
"""Check user permissions to access a node. We look up node permissions from
world to groups to users and match them with the computed user permissions.
If there is not match, we raise 403.
@ -93,8 +95,9 @@ def compute_allowed_methods(collection_name, resource, check_node_type=None):
return allowed_methods
def has_permissions(collection_name, resource, method, append_allowed_methods=False,
check_node_type=None):
def has_permissions(collection_name: str, resource: dict, method: str,
append_allowed_methods=False,
check_node_type: typing.Optional[str] = None):
"""Check user permissions to access a node. We look up node permissions from
world to groups to users and match them with the computed user permissions.

View File

@ -38,6 +38,8 @@ class UserClass(flask_login.UserMixin):
self.groups: typing.List[str] = [] # NOTE: these are stringified object IDs.
self.group_ids: typing.List[bson.ObjectId] = []
self.capabilities: typing.Set[str] = set()
self.nodes: dict = {} # see the 'nodes' key in eve_settings.py::user_schema.
self.badges_html: str = ''
# Lazily evaluated
self._has_organizations: typing.Optional[bool] = None
@ -56,6 +58,12 @@ class UserClass(flask_login.UserMixin):
user.email = db_user.get('email') or ''
user.username = db_user.get('username') or ''
user.full_name = db_user.get('full_name') or ''
user.badges_html = db_user.get('badges', {}).get('html') or ''
# Be a little more specific than just db_user['nodes']
user.nodes = {
'view_progress': db_user.get('nodes', {}).get('view_progress', {}),
}
# Derived properties
user.objectid = str(user.user_id or '')
@ -210,6 +218,11 @@ def login_user(oauth_token: str, *, load_from_db=False):
user = _load_user(oauth_token)
else:
user = UserClass(oauth_token)
login_user_object(user)
def login_user_object(user: UserClass):
"""Log in the given user."""
flask_login.login_user(user, remember=True)
g.current_user = user
user_authenticated.send(None)

View File

@ -1,8 +1,9 @@
import abc
import attr
import json
import logging
import typing
import attr
from rauth import OAuth2Service
from flask import current_app, url_for, request, redirect, session, Response
@ -15,6 +16,8 @@ class OAuthUserResponse:
id = attr.ib(validator=attr.validators.instance_of(str))
email = attr.ib(validator=attr.validators.instance_of(str))
access_token = attr.ib(validator=attr.validators.instance_of(str))
scopes: typing.List[str] = attr.ib(validator=attr.validators.instance_of(list))
class OAuthError(Exception):
@ -127,6 +130,7 @@ class OAuthSignIn(metaclass=abc.ABCMeta):
class BlenderIdSignIn(OAuthSignIn):
provider_name = 'blender-id'
scopes = ['email', 'badge']
def __init__(self):
from urllib.parse import urljoin
@ -140,12 +144,12 @@ class BlenderIdSignIn(OAuthSignIn):
client_secret=self.consumer_secret,
authorize_url=urljoin(base_url, 'oauth/authorize'),
access_token_url=urljoin(base_url, 'oauth/token'),
base_url='%s/api/' % base_url
base_url=urljoin(base_url, 'api/'),
)
def authorize(self):
return redirect(self.service.get_authorize_url(
scope='email',
scope=' '.join(self.scopes),
response_type='code',
redirect_uri=self.get_callback_url())
)
@ -159,7 +163,11 @@ class BlenderIdSignIn(OAuthSignIn):
session['blender_id_oauth_token'] = access_token
me = oauth_session.get('user').json()
return OAuthUserResponse(str(me['id']), me['email'])
# Blender ID doesn't tell us which scopes were granted by the user, so
# for now assume we got all the scopes we requested.
# (see https://github.com/jazzband/django-oauth-toolkit/issues/644)
return OAuthUserResponse(str(me['id']), me['email'], access_token, self.scopes)
class FacebookSignIn(OAuthSignIn):
@ -189,7 +197,7 @@ class FacebookSignIn(OAuthSignIn):
me = oauth_session.get('me?fields=id,email').json()
# TODO handle case when user chooses not to disclose en email
# see https://developers.facebook.com/docs/graph-api/reference/user/
return OAuthUserResponse(me['id'], me.get('email'))
return OAuthUserResponse(me['id'], me.get('email'), '', [])
class GoogleSignIn(OAuthSignIn):
@ -217,4 +225,4 @@ class GoogleSignIn(OAuthSignIn):
oauth_session = self.make_oauth_session()
me = oauth_session.get('userinfo').json()
return OAuthUserResponse(str(me['id']), me['email'])
return OAuthUserResponse(str(me['id']), me['email'], '', [])

183
pillar/badge_sync.py Normal file
View File

@ -0,0 +1,183 @@
import collections
import datetime
import logging
import typing
from urllib.parse import urljoin
import bson
import requests
from pillar import current_app
from pillar.api.utils import utcnow
SyncUser = collections.namedtuple('SyncUser', 'user_id token bid_user_id')
BadgeHTML = collections.namedtuple('BadgeHTML', 'html expires')
log = logging.getLogger(__name__)
class StopRefreshing(Exception):
"""Indicates that Blender ID is having problems.
Further badge refreshes should be put on hold to avoid bludgeoning
a suffering Blender ID.
"""
def find_users_to_sync() -> typing.Iterable[SyncUser]:
"""Return user information of syncable users with badges."""
now = utcnow()
tokens_coll = current_app.db('tokens')
cursor = tokens_coll.aggregate([
# Find all users who have a 'badge' scope in their OAuth token.
{'$match': {
'token': {'$exists': True},
'oauth_scopes': 'badge',
'expire_time': {'$gt': now},
}},
{'$lookup': {
'from': 'users',
'localField': 'user',
'foreignField': '_id',
'as': 'user'
}},
# Prevent 'user' from being an array.
{'$unwind': {'path': '$user'}},
# Get the Blender ID user ID only.
{'$unwind': {'path': '$user.auth'}},
{'$match': {'user.auth.provider': 'blender-id'}},
# Only select those users whose badge doesn't exist or has expired.
{'$match': {
'user.badges.expires': {'$not': {'$gt': now}}
}},
# Make sure that the badges that expire last are also refreshed last.
{'$sort': {'user.badges.expires': 1}},
# Reduce the document to the info we're after.
{'$project': {
'token': True,
'user._id': True,
'user.auth.user_id': True,
'user.badges.expires': True,
}},
])
log.debug('Aggregating tokens and users')
for user_info in cursor:
log.debug('User %s has badges %s',
user_info['user']['_id'], user_info['user'].get('badges'))
yield SyncUser(
user_id=user_info['user']['_id'],
token=user_info['token'],
bid_user_id=user_info['user']['auth']['user_id'])
def fetch_badge_html(session: requests.Session, user: SyncUser, size: str) \
-> str:
"""Fetch a Blender ID badge for this user.
:param session:
:param user:
:param size: Size indication for the badge images, see the Blender ID
documentation/code. As of this writing valid sizes are {'s', 'm', 'l'}.
"""
my_log = log.getChild('fetch_badge_html')
blender_id_endpoint = current_app.config['BLENDER_ID_ENDPOINT']
url = urljoin(blender_id_endpoint, f'api/badges/{user.bid_user_id}/html/{size}')
my_log.debug('Fetching badge HTML at %s for user %s', url, user.user_id)
try:
resp = session.get(url, headers={'Authorization': f'Bearer {user.token}'})
except requests.ConnectionError as ex:
my_log.warning('Unable to connect to Blender ID at %s: %s', url, ex)
raise StopRefreshing()
if resp.status_code == 204:
my_log.debug('No badges for user %s', user.user_id)
return ''
if resp.status_code == 403:
my_log.warning('Tried fetching %s for user %s but received a 403: %s',
url, user.user_id, resp.text)
return ''
if resp.status_code == 400:
my_log.warning('Blender ID did not accept our GET request at %s for user %s: %s',
url, user.user_id, resp.text)
return ''
if resp.status_code == 500:
my_log.warning('Blender ID returned an internal server error on %s for user %s, '
'aborting all badge refreshes: %s', url, user.user_id, resp.text)
raise StopRefreshing()
if resp.status_code == 404:
my_log.warning('Blender ID has no user %s for our user %s', user.bid_user_id, user.user_id)
return ''
resp.raise_for_status()
return resp.text
def refresh_all_badges(only_user_id: typing.Optional[bson.ObjectId] = None, *,
dry_run=False,
timelimit: datetime.timedelta):
"""Re-fetch all badges for all users, except when already refreshed recently.
:param only_user_id: Only refresh this user. This is expected to be used
sparingly during manual maintenance / debugging sessions only. It does
fetch all users to refresh, and in Python code skips all except the
given one.
:param dry_run: if True the changes are described in the log, but not performed.
:param timelimit: Refreshing will stop after this time. This allows for cron(-like)
jobs to run without overlapping, even when the number fo badges to refresh
becomes larger than possible within the period of the cron job.
"""
from requests.adapters import HTTPAdapter
my_log = log.getChild('fetch_badge_html')
# Test the config before we start looping over the world.
badge_expiry = badge_expiry_config()
if not badge_expiry or not isinstance(badge_expiry, datetime.timedelta):
raise ValueError('BLENDER_ID_BADGE_EXPIRY not configured properly, should be a timedelta')
session = requests.Session()
session.mount('https://', HTTPAdapter(max_retries=5))
users_coll = current_app.db('users')
deadline = utcnow() + timelimit
num_updates = 0
for user_info in find_users_to_sync():
if utcnow() > deadline:
my_log.info('Stopping badge refresh because the timelimit %s (H:MM:SS) was hit.',
timelimit)
break
if only_user_id and user_info.user_id != only_user_id:
my_log.debug('Skipping user %s', user_info.user_id)
continue
try:
badge_html = fetch_badge_html(session, user_info, 's')
except StopRefreshing:
my_log.error('Blender ID has internal problems, stopping badge refreshing at user %s',
user_info)
break
update = {'badges': {
'html': badge_html,
'expires': utcnow() + badge_expiry,
}}
num_updates += 1
my_log.info('Updating badges HTML for Blender ID %s, user %s',
user_info.bid_user_id, user_info.user_id)
if not dry_run:
result = users_coll.update_one({'_id': user_info.user_id},
{'$set': update})
if result.matched_count != 1:
my_log.warning('Unable to update badges for user %s', user_info.user_id)
my_log.info('Updated badges of %d users%s', num_updates, ' (dry-run)' if dry_run else '')
def badge_expiry_config() -> datetime.timedelta:
return current_app.config.get('BLENDER_ID_BADGE_EXPIRY')

20
pillar/celery/badges.py Normal file
View File

@ -0,0 +1,20 @@
"""Badge HTML synchronisation.
Note that this module can only be imported when an application context is
active. Best to late-import this in the functions where it's needed.
"""
import datetime
import logging
from pillar import current_app, badge_sync
log = logging.getLogger(__name__)
@current_app.celery.task(ignore_result=True)
def sync_badges_for_users(timelimit_seconds: int):
"""Synchronises Blender ID badges for the most-urgent users."""
timelimit = datetime.timedelta(seconds=timelimit_seconds)
log.info('Refreshing badges, timelimit is %s (H:MM:SS)', timelimit)
badge_sync.refresh_all_badges(timelimit=timelimit)

View File

@ -13,6 +13,7 @@ from pillar.cli.maintenance import manager_maintenance
from pillar.cli.operations import manager_operations
from pillar.cli.setup import manager_setup
from pillar.cli.elastic import manager_elastic
from . import badges
from pillar.cli import translations
@ -24,3 +25,4 @@ manager.add_command("maintenance", manager_maintenance)
manager.add_command("setup", manager_setup)
manager.add_command("operations", manager_operations)
manager.add_command("elastic", manager_elastic)
manager.add_command("badges", badges.manager)

39
pillar/cli/badges.py Normal file
View File

@ -0,0 +1,39 @@
import datetime
import logging
from flask_script import Manager
from pillar import current_app, badge_sync
from pillar.api.utils import utcnow
log = logging.getLogger(__name__)
manager = Manager(current_app, usage="Badge operations")
@manager.option('-u', '--user', dest='email', default='', help='Email address of the user to sync')
@manager.option('-a', '--all', dest='sync_all', action='store_true', default=False,
help='Sync all users')
@manager.option('--go', action='store_true', default=False,
help='Actually perform the sync; otherwise it is a dry-run.')
def sync(email: str = '', sync_all: bool=False, go: bool=False):
if bool(email) == bool(sync_all):
raise ValueError('Use either --user or --all.')
if email:
users_coll = current_app.db('users')
db_user = users_coll.find_one({'email': email}, projection={'_id': True})
if not db_user:
raise ValueError(f'No user with email {email!r} found')
specific_user = db_user['_id']
else:
specific_user = None
if not go:
log.info('Performing dry-run, not going to change the user database.')
start_time = utcnow()
badge_sync.refresh_all_badges(specific_user, dry_run=not go,
timelimit=datetime.timedelta(hours=1))
end_time = utcnow()
log.info('%s took %s (H:MM:SS)',
'Updating user badges' if go else 'Dry-run',
end_time - start_time)

View File

@ -559,50 +559,6 @@ def replace_pillar_node_type_schemas(project_url=None, all_projects=False, missi
projects_changed, projects_seen)
@manager_maintenance.command
def remarkdown_comments():
"""Retranslates all Markdown to HTML for all comment nodes.
"""
from pillar.api.nodes import convert_markdown
nodes_collection = current_app.db()['nodes']
comments = nodes_collection.find({'node_type': 'comment'},
projection={'properties.content': 1,
'node_type': 1})
updated = identical = skipped = errors = 0
for node in comments:
convert_markdown(node)
node_id = node['_id']
try:
content_html = node['properties']['content_html']
except KeyError:
log.warning('Node %s has no content_html', node_id)
skipped += 1
continue
result = nodes_collection.update_one(
{'_id': node_id},
{'$set': {'properties.content_html': content_html}}
)
if result.matched_count != 1:
log.error('Unable to update node %s', node_id)
errors += 1
continue
if result.modified_count:
updated += 1
else:
identical += 1
log.info('updated : %i', updated)
log.info('identical: %i', identical)
log.info('skipped : %i', skipped)
log.info('errors : %i', errors)
@manager_maintenance.option('-p', '--project', dest='proj_url', nargs='?',
help='Project URL')
@manager_maintenance.option('-a', '--all', dest='all_projects', action='store_true', default=False,

View File

@ -1,6 +1,8 @@
from collections import defaultdict
import datetime
import os.path
from os import getenv
from collections import defaultdict
import requests.certs
# Certificate file for communication with other systems.
@ -29,6 +31,7 @@ DEBUG = False
SECRET_KEY = ''
# Authentication token hashing key. If empty falls back to UTF8-encoded SECRET_KEY with a warning.
# Not used to hash new tokens, but it is used to check pre-existing hashed tokens.
AUTH_TOKEN_HMAC_KEY = b''
# Authentication settings
@ -203,8 +206,18 @@ CELERY_BEAT_SCHEDULE = {
'schedule': 600, # every N seconds
'args': ('gcs', 100)
},
'refresh-blenderid-badges': {
'task': 'pillar.celery.badges.sync_badges_for_users',
'schedule': 600, # every N seconds
'args': (540, ), # time limit in seconds, keep shorter than 'schedule'
}
}
# Badges will be re-fetched every timedelta.
# TODO(Sybren): A proper value should be determined after we actually have users with badges.
BLENDER_ID_BADGE_EXPIRY = datetime.timedelta(hours=4)
# Mapping from user role to capabilities obtained by users with that role.
USER_CAPABILITIES = defaultdict(**{
'subscriber': {'subscriber', 'home-project'},

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('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

@ -1,6 +1,7 @@
# -*- encoding: utf-8 -*-
import base64
import contextlib
import copy
import datetime
import json
@ -10,11 +11,7 @@ import pathlib
import sys
import typing
import unittest.mock
try:
from urllib.parse import urlencode
except ImportError:
from urllib.parse import urlencode
from urllib.parse import urlencode, urljoin
from bson import ObjectId, tz_util
@ -186,7 +183,7 @@ class AbstractPillarTest(TestMinimal):
else:
self.ensure_project_exists()
with self.app.test_request_context():
with self.app.app_context():
files_collection = self.app.data.driver.db['files']
assert isinstance(files_collection, pymongo.collection.Collection)
@ -327,15 +324,46 @@ class AbstractPillarTest(TestMinimal):
return user
def create_valid_auth_token(self, user_id, token='token'):
@contextlib.contextmanager
def login_as(self, user_id: typing.Union[str, ObjectId]):
"""Context manager, within the context the app context is active and the user logged in.
The logging-in happens when a request starts, so it's only active when
e.g. self.get() or self.post() or somesuch request is used.
"""
from pillar.auth import UserClass, login_user_object
if isinstance(user_id, str):
user_oid = ObjectId(user_id)
elif isinstance(user_id, ObjectId):
user_oid = user_id
else:
raise TypeError(f'invalid type {type(user_id)} for parameter user_id')
user_doc = self.fetch_user_from_db(user_oid)
def signal_handler(sender, **kwargs):
login_user_object(user)
with self.app.app_context():
user = UserClass.construct('', user_doc)
with flask.request_started.connected_to(signal_handler, self.app):
yield
# TODO: rename to 'create_auth_token' now that 'expire_in_days' can be negative.
def create_valid_auth_token(self,
user_id: ObjectId,
token='token',
*,
oauth_scopes: typing.Optional[typing.List[str]]=None,
expire_in_days=1) -> dict:
from pillar.api.utils import utcnow
future = utcnow() + datetime.timedelta(days=1)
future = utcnow() + datetime.timedelta(days=expire_in_days)
with self.app.test_request_context():
from pillar.api.utils import authentication as auth
token_data = auth.store_token(user_id, token, future, None)
token_data = auth.store_token(user_id, token, future, oauth_scopes=oauth_scopes)
return token_data
@ -365,7 +393,7 @@ class AbstractPillarTest(TestMinimal):
return user_id
def create_node(self, node_doc):
def create_node(self, node_doc) -> ObjectId:
"""Creates a node, returning its ObjectId. """
with self.app.test_request_context():
@ -407,7 +435,7 @@ class AbstractPillarTest(TestMinimal):
"""Sets up Responses to mock unhappy validation flow."""
responses.add(responses.POST,
'%s/u/validate_token' % self.app.config['BLENDER_ID_ENDPOINT'],
urljoin(self.app.config['BLENDER_ID_ENDPOINT'], 'u/validate_token'),
json={'status': 'fail'},
status=403)
@ -415,7 +443,7 @@ class AbstractPillarTest(TestMinimal):
"""Sets up Responses to mock happy validation flow."""
responses.add(responses.POST,
'%s/u/validate_token' % self.app.config['BLENDER_ID_ENDPOINT'],
urljoin(self.app.config['BLENDER_ID_ENDPOINT'], 'u/validate_token'),
json=BLENDER_ID_USER_RESPONSE,
status=200)

View File

@ -1,6 +1,6 @@
"""Flask configuration file for unit testing."""
BLENDER_ID_ENDPOINT = 'http://id.local:8001' # Non existant server
BLENDER_ID_ENDPOINT = 'http://id.local:8001/' # Non existant server
SERVER_NAME = 'localhost.local'
PILLAR_SERVER_ENDPOINT = 'http://localhost.local/api/'

View File

@ -19,6 +19,7 @@ from pillar.web.nodes.routes import url_for_node
from pillar.web.nodes.forms import get_node_form
import pillar.web.nodes.attachments
from pillar.web.projects.routes import project_update_nodes_list
from pillar.web.projects.routes import project_navigation_links
log = logging.getLogger(__name__)
@ -61,16 +62,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 +90,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:
@ -112,24 +108,19 @@ def posts_view(project_id=None, project_url=None, url=None, *, archive=False, pa
else:
project.blog_archive_prev = None
title = 'blog_main' if is_main_project else 'blog'
pages = Node.all({
'where': {'project': project._id, 'node_type': 'page'},
'projection': {'name': 1}}, api=api)
navigation_links = project_navigation_links(project, api)
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'],
project=project,
title=title,
node_type_post=project.get_node_type('post'),
can_create_blog_posts=can_create_blog_posts,
pages=pages._items,
navigation_links=navigation_links,
api=api)

View File

@ -94,6 +94,16 @@ def find_for_post(project, node):
url=node.properties.url)
@register_node_finder('page')
def find_for_page(project, node):
"""Returns the URL for a page."""
project_id = project['_id']
the_project = project_url(project_id, project=project)
return url_for('projects.view_node', project_url=the_project.url, node_id=node.properties.url)
def find_for_other(project, node):
"""Fallback: Assets, textures, and other node types.

View File

@ -1,9 +1,10 @@
import functools
import logging
import typing
from datetime import datetime
from datetime import date
import pillarsdk
from flask import current_app
from flask_wtf import FlaskForm
from wtforms import StringField
from wtforms import DateField
@ -17,6 +18,8 @@ from wtforms import DateTimeField
from wtforms import SelectMultipleField
from wtforms import FieldList
from wtforms.validators import DataRequired
from pillar import current_app
from pillar.web.utils import system_util
from pillar.web.utils.forms import FileSelectField
from pillar.web.utils.forms import CustomFormField
@ -44,6 +47,13 @@ def iter_node_properties(node_type):
yield prop_name, prop_schema, prop_fschema
@functools.lru_cache(maxsize=1)
def tag_choices() -> typing.List[typing.Tuple[str, str]]:
"""Return (value, label) tuples for the NODE_TAGS config setting."""
tags = current_app.config.get('NODE_TAGS') or []
return [(tag, tag.title()) for tag in tags] # (value, label) tuples
def add_form_properties(form_class, node_type):
"""Add fields to a form based on the node and form schema provided.
:type node_schema: dict
@ -60,7 +70,9 @@ def add_form_properties(form_class, node_type):
# Recursive call if detects a dict
field_type = schema_prop['type']
if field_type == 'dict':
if prop_name == 'tags' and field_type == 'list':
field = SelectMultipleField(choices=tag_choices())
elif field_type == 'dict':
assert prop_name == 'attachments'
field = attachments.attachment_form_group_create(schema_prop)
elif field_type == 'list':

View File

@ -24,6 +24,7 @@ from pillar import current_app
from pillar.api.utils import utcnow
from pillar.web import system_util
from pillar.web import utils
from pillar.web.nodes import finders
from pillar.web.utils.jstree import jstree_get_children
import pillar.extension
@ -302,6 +303,51 @@ def view(project_url):
'header_video_node': header_video_node})
def project_navigation_links(project, api) -> list:
"""Returns a list of nodes for the project, for top navigation display.
Args:
project: A Project object.
api: the api client credential.
Returns:
A list of links for the Project.
For example we display a link to the project blog if present, as well
as pages. The list is structured as follows:
[{'url': '/p/spring/about', 'label': 'About'},
{'url': '/p/spring/blog', 'label': 'Blog'}]
"""
links = []
# Fetch the blog
blog = Node.find_first({
'where': {'project': project._id, 'node_type': 'blog', '_deleted': {'$ne': True}},
'projection': {
'name': 1,
}
}, api=api)
if blog:
links.append({'url': finders.find_url_for_node(blog), 'label': blog.name, 'slug': 'blog'})
# Fetch pages
pages = Node.all({
'where': {'project': project._id, 'node_type': 'page', '_deleted': {'$ne': True}},
'projection': {
'name': 1,
'properties.url': 1
}
}, api=api)
# Process the results and append the links to the list
for p in pages._items:
links.append({'url': finders.find_url_for_node(p), 'label': p.name, 'slug': p.properties.url})
return links
def render_project(project, api, extra_context=None, template_name=None):
project.picture_square = utils.get_file(project.picture_square, api=api)
project.picture_header = utils.get_file(project.picture_header, api=api)
@ -370,6 +416,8 @@ def render_project(project, api, extra_context=None, template_name=None):
extension_sidebar_links = current_app.extension_sidebar_links(project)
navigation_links = project_navigation_links(project, api)
return render_template(template_name,
api=api,
project=project,
@ -378,6 +426,7 @@ def render_project(project, api, extra_context=None, template_name=None):
show_project=True,
og_picture=project.picture_header,
activity_stream=activity_stream,
navigation_links=navigation_links,
extension_sidebar_links=extension_sidebar_links,
**extra_context)
@ -447,16 +496,14 @@ def view_node(project_url, node_id):
# Append _theatre to load the proper template
theatre = '_theatre' if theatre_mode else ''
navigation_links = project_navigation_links(project, api)
if node.node_type == 'page':
pages = Node.all({
'where': {'project': project._id, 'node_type': 'page'},
'projection': {'name': 1}}, api=api)
return render_template('nodes/custom/page/view_embed.html',
api=api,
node=node,
project=project,
pages=pages._items,
navigation_links=navigation_links,
og_picture=og_picture,)
extension_sidebar_links = current_app.extension_sidebar_links(project)
@ -468,6 +515,7 @@ def view_node(project_url, node_id):
show_node=True,
show_project=False,
og_picture=og_picture,
navigation_links=navigation_links,
extension_sidebar_links=extension_sidebar_links)

View File

@ -872,12 +872,6 @@
"code": 61930,
"src": "fontawesome"
},
{
"uid": "31972e4e9d080eaa796290349ae6c1fd",
"css": "users",
"code": 59502,
"src": "fontawesome"
},
{
"uid": "c8585e1e5b0467f28b70bce765d5840c",
"css": "clipboard-copy",
@ -990,6 +984,30 @@
"code": 59394,
"src": "entypo"
},
{
"uid": "347c38a8b96a509270fdcabc951e7571",
"css": "database",
"code": 61888,
"src": "fontawesome"
},
{
"uid": "3a6f0140c3a390bdb203f56d1bfdefcb",
"css": "speed",
"code": 59471,
"src": "entypo"
},
{
"uid": "4c1ef492f1d2c39a2250ae457cee2a6e",
"css": "social-instagram",
"code": 61805,
"src": "fontawesome"
},
{
"uid": "e36d581e4f2844db345bddc205d15dda",
"css": "users",
"code": 59507,
"src": "elusive"
},
{
"uid": "053a214a098a9453877363eeb45f004e",
"css": "log-in",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -33,7 +33,8 @@ def get_user_info(user_id):
# TODO: put those fields into a config var or module-level global.
return {'email': user.email,
'full_name': user.full_name,
'username': user.username}
'username': user.username,
'badges_html': (user.badges and user.badges.html) or ''}
def setup_app(app):

View File

@ -48,6 +48,10 @@ def oauth_authorize(provider):
@blueprint.route('/oauth/<provider>/authorized')
def oauth_callback(provider):
import datetime
from pillar.api.utils.authentication import store_token
from pillar.api.utils import utcnow
if current_user.is_authenticated:
return redirect(url_for('main.homepage'))
@ -65,7 +69,17 @@ def oauth_callback(provider):
user_info = {'id': oauth_user.id, 'email': oauth_user.email, 'full_name': ''}
db_user = find_user_in_db(user_info, provider=provider)
db_id, status = upsert_user(db_user)
token = generate_and_store_token(db_id)
# TODO(Sybren): If the user doesn't have any badges, but the access token
# does have 'badge' scope, we should fetch the badges in the background.
if oauth_user.access_token:
# TODO(Sybren): make nr of days configurable, or get from OAuthSignIn subclass.
token_expiry = utcnow() + datetime.timedelta(days=15)
token = store_token(db_id, oauth_user.access_token, token_expiry,
oauth_scopes=oauth_user.scopes)
else:
token = generate_and_store_token(db_id)
# Login user
pillar.auth.login_user(token['token'], load_from_db=True)

View File

@ -62,7 +62,7 @@ def jstree_get_children(node_id, project_id=None):
'where': {
'$and': [
{'node_type': {'$regex': '^(?!attract_)'}},
{'node_type': {'$not': {'$in': ['comment', 'post']}}},
{'node_type': {'$not': {'$in': ['comment', 'post', 'blog', 'page']}}},
],
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,116 +0,0 @@
(function () {
var output, Converter;
if (typeof exports === "object" && typeof require === "function") { // we're in a CommonJS (e.g. Node.js) module
output = exports;
Converter = require("./Markdown.Converter").Converter;
} else {
output = window.Markdown;
Converter = output.Converter;
}
output.getSanitizingConverter = function () {
var converter = new Converter();
converter.hooks.chain("postConversion", sanitizeHtml);
converter.hooks.chain("postConversion", balanceTags);
return converter;
}
function sanitizeHtml(html) {
return html.replace(/<[^>]*>?/gi, sanitizeTag);
}
// (tags that can be opened/closed) | (tags that stand alone)
var basic_tag_whitelist = /^(<\/?(b|blockquote|code|del|dd|dl|dt|em|h1|h2|h3|i|iframe|kbd|li|ol(?: start="\d+")?|p|pre|s|sup|sub|strong|strike|ul|video)>|<(br|hr)\s?\/?>)$/i;
// <a href="url..." optional title>|</a>
var a_white = /^(<a\shref="((https?|ftp):\/\/|\/)[-A-Za-z0-9+&@#\/%?=~_|!:,.;\(\)*[\]$]+"(\stitle="[^"<>]+")?(\sclass="[^"<>]+")?\s?>|<\/a>)$/i;
// Cloud custom: Allow iframe embed from YouTube, Vimeo and SoundCloud
var iframe_youtube = /^(<iframe(\swidth="\d{1,3}")?(\sheight="\d{1,3}")\ssrc="((https?):\/\/|\/)[-A-Za-z0-9+&@#\/%?=~_|!:,.;\(\)*[\]$]+"(\sframeborder="\d{1,3}")?(\sallowfullscreen)\s?>|<\/iframe>)$/i;
var iframe_vimeo = /^(<iframe(\ssrc="((https?):\/\/|\/)[-A-Za-z0-9+&@#\/%?=~_|!:,.;\(\)*[\]$]+"?\swidth="\d{1,3}")?(\sheight="\d{1,3}")?(\sframeborder="\d{1,3}")?(\swebkitallowfullscreen)\s?(\smozallowfullscreen)\s?(\sallowfullscreen)\s?>|<\/iframe>)$/i;
var iframe_soundcloud = /^(<iframe(\swidth="\d{1,3}\%")?(\sheight="\d{1,3}")?(\sscrolling="(?:yes|no)")?(\sframeborder="(?:yes|no)")\ssrc="((https?):\/\/|\/)[-A-Za-z0-9+&@#\/%?=~_|!:,.;\(\)*[\]$]+"\s?>|<\/iframe>)$/i;
var iframe_googlestorage = /^(<iframe\ssrc="https:\/\/storage.googleapis.com\/institute-storage\/.+"\sstyle=".*"\s?>|<\/iframe>)$/i;
// <img src="url..." optional width optional height optional alt optional title
var img_white = /^(<img\ssrc="(https?:\/\/|\/)[-A-Za-z0-9+&@#\/%?=~_|!:,.;\(\)*[\]$]+"(\swidth="\d{1,3}")?(\sheight="\d{1,3}")?(\salt="[^"<>]*")?(\stitle="[^"<>]*")?\s?\/?>)$/i;
var video_white = /<video(.*?)>/;
function sanitizeTag(tag) {
if (tag.match(basic_tag_whitelist) || tag.match(a_white) || tag.match(img_white) || tag.match(iframe_youtube) || tag.match(iframe_vimeo) || tag.match(iframe_soundcloud) || tag.match(iframe_googlestorage) || tag.match(video_white)) {
return tag;
} else {
return "";
}
}
/// <summary>
/// attempt to balance HTML tags in the html string
/// by removing any unmatched opening or closing tags
/// IMPORTANT: we *assume* HTML has *already* been
/// sanitized and is safe/sane before balancing!
///
/// adapted from CODESNIPPET: A8591DBA-D1D3-11DE-947C-BA5556D89593
/// </summary>
function balanceTags(html) {
if (html == "")
return "";
var re = /<\/?\w+[^>]*(\s|$|>)/g;
// convert everything to lower case; this makes
// our case insensitive comparisons easier
var tags = html.toLowerCase().match(re);
// no HTML tags present? nothing to do; exit now
var tagcount = (tags || []).length;
if (tagcount == 0)
return html;
var tagname, tag;
var ignoredtags = "<p><img><br><li><hr>";
var match;
var tagpaired = [];
var tagremove = [];
var needsRemoval = false;
// loop through matched tags in forward order
for (var ctag = 0; ctag < tagcount; ctag++) {
tagname = tags[ctag].replace(/<\/?(\w+).*/, "$1");
// skip any already paired tags
// and skip tags in our ignore list; assume they're self-closed
if (tagpaired[ctag] || ignoredtags.search("<" + tagname + ">") > -1)
continue;
tag = tags[ctag];
match = -1;
if (!/^<\//.test(tag)) {
// this is an opening tag
// search forwards (next tags), look for closing tags
for (var ntag = ctag + 1; ntag < tagcount; ntag++) {
if (!tagpaired[ntag] && tags[ntag] == "</" + tagname + ">") {
match = ntag;
break;
}
}
}
if (match == -1)
needsRemoval = tagremove[ctag] = true; // mark for removal
else
tagpaired[match] = true; // mark paired
}
if (!needsRemoval)
return html;
// delete all orphaned tags from the string
var ctag = 0;
html = html.replace(re, function (match) {
var res = tagremove[ctag] ? "" : match;
ctag++;
return res;
});
return html;
}
})();

File diff suppressed because it is too large Load Diff

View File

@ -1,874 +0,0 @@
(function () {
// A quick way to make sure we're only keeping span-level tags when we need to.
// This isn't supposed to be foolproof. It's just a quick way to make sure we
// keep all span-level tags returned by a pagedown converter. It should allow
// all span-level tags through, with or without attributes.
var inlineTags = new RegExp(['^(<\\/?(a|abbr|acronym|applet|area|b|basefont|',
'bdo|big|button|cite|code|del|dfn|em|figcaption|',
'font|i|iframe|img|input|ins|kbd|label|map|',
'mark|meter|object|param|progress|q|ruby|rp|rt|s|',
'samp|script|select|small|span|strike|strong|',
'sub|sup|textarea|time|tt|u|var|wbr)[^>]*>|',
'<(br)\\s?\\/?>)$'].join(''), 'i');
/******************************************************************
* Utility Functions *
*****************************************************************/
// patch for ie7
if (!Array.indexOf) {
Array.prototype.indexOf = function(obj) {
for (var i = 0; i < this.length; i++) {
if (this[i] == obj) {
return i;
}
}
return -1;
};
}
function trim(str) {
return str.replace(/^\s+|\s+$/g, '');
}
function rtrim(str) {
return str.replace(/\s+$/g, '');
}
// Remove one level of indentation from text. Indent is 4 spaces.
function outdent(text) {
return text.replace(new RegExp('^(\\t|[ ]{1,4})', 'gm'), '');
}
function contains(str, substr) {
return str.indexOf(substr) != -1;
}
// Sanitize html, removing tags that aren't in the whitelist
function sanitizeHtml(html, whitelist) {
return html.replace(/<[^>]*>?/gi, function(tag) {
return tag.match(whitelist) ? tag : '';
});
}
// Merge two arrays, keeping only unique elements.
function union(x, y) {
var obj = {};
for (var i = 0; i < x.length; i++)
obj[x[i]] = x[i];
for (i = 0; i < y.length; i++)
obj[y[i]] = y[i];
var res = [];
for (var k in obj) {
if (obj.hasOwnProperty(k))
res.push(obj[k]);
}
return res;
}
// JS regexes don't support \A or \Z, so we add sentinels, as Pagedown
// does. In this case, we add the ascii codes for start of text (STX) and
// end of text (ETX), an idea borrowed from:
// https://github.com/tanakahisateru/js-markdown-extra
function addAnchors(text) {
if(text.charAt(0) != '\x02')
text = '\x02' + text;
if(text.charAt(text.length - 1) != '\x03')
text = text + '\x03';
return text;
}
// Remove STX and ETX sentinels.
function removeAnchors(text) {
if(text.charAt(0) == '\x02')
text = text.substr(1);
if(text.charAt(text.length - 1) == '\x03')
text = text.substr(0, text.length - 1);
return text;
}
// Convert markdown within an element, retaining only span-level tags
function convertSpans(text, extra) {
return sanitizeHtml(convertAll(text, extra), inlineTags);
}
// Convert internal markdown using the stock pagedown converter
function convertAll(text, extra) {
var result = extra.blockGamutHookCallback(text);
// We need to perform these operations since we skip the steps in the converter
result = unescapeSpecialChars(result);
result = result.replace(/~D/g, "$$").replace(/~T/g, "~");
result = extra.previousPostConversion(result);
return result;
}
// Convert escaped special characters
function processEscapesStep1(text) {
// Markdown extra adds two escapable characters, `:` and `|`
return text.replace(/\\\|/g, '~I').replace(/\\:/g, '~i');
}
function processEscapesStep2(text) {
return text.replace(/~I/g, '|').replace(/~i/g, ':');
}
// Duplicated from PageDown converter
function unescapeSpecialChars(text) {
// Swap back in all the special characters we've hidden.
text = text.replace(/~E(\d+)E/g, function(wholeMatch, m1) {
var charCodeToReplace = parseInt(m1);
return String.fromCharCode(charCodeToReplace);
});
return text;
}
function slugify(text) {
return text.toLowerCase()
.replace(/\s+/g, '-') // Replace spaces with -
.replace(/[^\w\-]+/g, '') // Remove all non-word chars
.replace(/\-\-+/g, '-') // Replace multiple - with single -
.replace(/^-+/, '') // Trim - from start of text
.replace(/-+$/, ''); // Trim - from end of text
}
/*****************************************************************************
* Markdown.Extra *
****************************************************************************/
Markdown.Extra = function() {
// For converting internal markdown (in tables for instance).
// This is necessary since these methods are meant to be called as
// preConversion hooks, and the Markdown converter passed to init()
// won't convert any markdown contained in the html tags we return.
this.converter = null;
// Stores html blocks we generate in hooks so that
// they're not destroyed if the user is using a sanitizing converter
this.hashBlocks = [];
// Stores footnotes
this.footnotes = {};
this.usedFootnotes = [];
// Special attribute blocks for fenced code blocks and headers enabled.
this.attributeBlocks = false;
// Fenced code block options
this.googleCodePrettify = false;
this.highlightJs = false;
// Table options
this.tableClass = '';
this.tabWidth = 4;
};
Markdown.Extra.init = function(converter, options) {
// Each call to init creates a new instance of Markdown.Extra so it's
// safe to have multiple converters, with different options, on a single page
var extra = new Markdown.Extra();
var postNormalizationTransformations = [];
var preBlockGamutTransformations = [];
var postSpanGamutTransformations = [];
var postConversionTransformations = ["unHashExtraBlocks"];
options = options || {};
options.extensions = options.extensions || ["all"];
if (contains(options.extensions, "all")) {
options.extensions = ["tables", "fenced_code_gfm", "def_list", "attr_list", "footnotes", "smartypants", "strikethrough", "newlines"];
}
preBlockGamutTransformations.push("wrapHeaders");
if (contains(options.extensions, "attr_list")) {
postNormalizationTransformations.push("hashFcbAttributeBlocks");
preBlockGamutTransformations.push("hashHeaderAttributeBlocks");
postConversionTransformations.push("applyAttributeBlocks");
extra.attributeBlocks = true;
}
if (contains(options.extensions, "fenced_code_gfm")) {
// This step will convert fcb inside list items and blockquotes
preBlockGamutTransformations.push("fencedCodeBlocks");
// This extra step is to prevent html blocks hashing and link definition/footnotes stripping inside fcb
postNormalizationTransformations.push("fencedCodeBlocks");
}
if (contains(options.extensions, "tables")) {
preBlockGamutTransformations.push("tables");
}
if (contains(options.extensions, "def_list")) {
preBlockGamutTransformations.push("definitionLists");
}
if (contains(options.extensions, "footnotes")) {
postNormalizationTransformations.push("stripFootnoteDefinitions");
preBlockGamutTransformations.push("doFootnotes");
postConversionTransformations.push("printFootnotes");
}
if (contains(options.extensions, "smartypants")) {
postConversionTransformations.push("runSmartyPants");
}
if (contains(options.extensions, "strikethrough")) {
postSpanGamutTransformations.push("strikethrough");
}
if (contains(options.extensions, "newlines")) {
postSpanGamutTransformations.push("newlines");
}
converter.hooks.chain("postNormalization", function(text) {
return extra.doTransform(postNormalizationTransformations, text) + '\n';
});
converter.hooks.chain("preBlockGamut", function(text, blockGamutHookCallback) {
// Keep a reference to the block gamut callback to run recursively
extra.blockGamutHookCallback = blockGamutHookCallback;
text = processEscapesStep1(text);
text = extra.doTransform(preBlockGamutTransformations, text) + '\n';
text = processEscapesStep2(text);
return text;
});
converter.hooks.chain("postSpanGamut", function(text) {
return extra.doTransform(postSpanGamutTransformations, text);
});
// Keep a reference to the hook chain running before doPostConversion to apply on hashed extra blocks
extra.previousPostConversion = converter.hooks.postConversion;
converter.hooks.chain("postConversion", function(text) {
text = extra.doTransform(postConversionTransformations, text);
// Clear state vars that may use unnecessary memory
extra.hashBlocks = [];
extra.footnotes = {};
extra.usedFootnotes = [];
return text;
});
if ("highlighter" in options) {
extra.googleCodePrettify = options.highlighter === 'prettify';
extra.highlightJs = options.highlighter === 'highlight';
}
if ("table_class" in options) {
extra.tableClass = options.table_class;
}
extra.converter = converter;
// Caller usually won't need this, but it's handy for testing.
return extra;
};
// Do transformations
Markdown.Extra.prototype.doTransform = function(transformations, text) {
for(var i = 0; i < transformations.length; i++)
text = this[transformations[i]](text);
return text;
};
// Return a placeholder containing a key, which is the block's index in the
// hashBlocks array. We wrap our output in a <p> tag here so Pagedown won't.
Markdown.Extra.prototype.hashExtraBlock = function(block) {
return '\n<p>~X' + (this.hashBlocks.push(block) - 1) + 'X</p>\n';
};
Markdown.Extra.prototype.hashExtraInline = function(block) {
return '~X' + (this.hashBlocks.push(block) - 1) + 'X';
};
// Replace placeholder blocks in `text` with their corresponding
// html blocks in the hashBlocks array.
Markdown.Extra.prototype.unHashExtraBlocks = function(text) {
var self = this;
function recursiveUnHash() {
var hasHash = false;
text = text.replace(/(?:<p>)?~X(\d+)X(?:<\/p>)?/g, function(wholeMatch, m1) {
hasHash = true;
var key = parseInt(m1, 10);
return self.hashBlocks[key];
});
if(hasHash === true) {
recursiveUnHash();
}
}
recursiveUnHash();
return text;
};
// Wrap headers to make sure they won't be in def lists
Markdown.Extra.prototype.wrapHeaders = function(text) {
function wrap(text) {
return '\n' + text + '\n';
}
text = text.replace(/^.+[ \t]*\n=+[ \t]*\n+/gm, wrap);
text = text.replace(/^.+[ \t]*\n-+[ \t]*\n+/gm, wrap);
text = text.replace(/^\#{1,6}[ \t]*.+?[ \t]*\#*\n+/gm, wrap);
return text;
};
/******************************************************************
* Attribute Blocks *
*****************************************************************/
// TODO: use sentinels. Should we just add/remove them in doConversion?
// TODO: better matches for id / class attributes
var attrBlock = "\\{[ \\t]*((?:[#.][-_:a-zA-Z0-9]+[ \\t]*)+)\\}";
var hdrAttributesA = new RegExp("^(#{1,6}.*#{0,6})[ \\t]+" + attrBlock + "[ \\t]*(?:\\n|0x03)", "gm");
var hdrAttributesB = new RegExp("^(.*)[ \\t]+" + attrBlock + "[ \\t]*\\n" +
"(?=[\\-|=]+\\s*(?:\\n|0x03))", "gm"); // underline lookahead
var fcbAttributes = new RegExp("^(```[^`\\n]*)[ \\t]+" + attrBlock + "[ \\t]*\\n" +
"(?=([\\s\\S]*?)\\n```[ \\t]*(\\n|0x03))", "gm");
// Extract headers attribute blocks, move them above the element they will be
// applied to, and hash them for later.
Markdown.Extra.prototype.hashHeaderAttributeBlocks = function(text) {
var self = this;
function attributeCallback(wholeMatch, pre, attr) {
return '<p>~XX' + (self.hashBlocks.push(attr) - 1) + 'XX</p>\n' + pre + "\n";
}
text = text.replace(hdrAttributesA, attributeCallback); // ## headers
text = text.replace(hdrAttributesB, attributeCallback); // underline headers
return text;
};
// Extract FCB attribute blocks, move them above the element they will be
// applied to, and hash them for later.
Markdown.Extra.prototype.hashFcbAttributeBlocks = function(text) {
// TODO: use sentinels. Should we just add/remove them in doConversion?
// TODO: better matches for id / class attributes
var self = this;
function attributeCallback(wholeMatch, pre, attr) {
return '<p>~XX' + (self.hashBlocks.push(attr) - 1) + 'XX</p>\n' + pre + "\n";
}
return text.replace(fcbAttributes, attributeCallback);
};
Markdown.Extra.prototype.applyAttributeBlocks = function(text) {
var self = this;
var blockRe = new RegExp('<p>~XX(\\d+)XX</p>[\\s]*' +
'(?:<(h[1-6]|pre)(?: +class="(\\S+)")?(>[\\s\\S]*?</\\2>))', "gm");
text = text.replace(blockRe, function(wholeMatch, k, tag, cls, rest) {
if (!tag) // no following header or fenced code block.
return '';
// get attributes list from hash
var key = parseInt(k, 10);
var attributes = self.hashBlocks[key];
// get id
var id = attributes.match(/#[^\s#.]+/g) || [];
var idStr = id[0] ? ' id="' + id[0].substr(1, id[0].length - 1) + '"' : '';
// get classes and merge with existing classes
var classes = attributes.match(/\.[^\s#.]+/g) || [];
for (var i = 0; i < classes.length; i++) // Remove leading dot
classes[i] = classes[i].substr(1, classes[i].length - 1);
var classStr = '';
if (cls)
classes = union(classes, [cls]);
if (classes.length > 0)
classStr = ' class="' + classes.join(' ') + '"';
return "<" + tag + idStr + classStr + rest;
});
return text;
};
/******************************************************************
* Tables *
*****************************************************************/
// Find and convert Markdown Extra tables into html.
Markdown.Extra.prototype.tables = function(text) {
var self = this;
var leadingPipe = new RegExp(
['^' ,
'[ ]{0,3}' , // Allowed whitespace
'[|]' , // Initial pipe
'(.+)\\n' , // $1: Header Row
'[ ]{0,3}' , // Allowed whitespace
'[|]([ ]*[-:]+[-| :]*)\\n' , // $2: Separator
'(' , // $3: Table Body
'(?:[ ]*[|].*\\n?)*' , // Table rows
')',
'(?:\\n|$)' // Stop at final newline
].join(''),
'gm'
);
var noLeadingPipe = new RegExp(
['^' ,
'[ ]{0,3}' , // Allowed whitespace
'(\\S.*[|].*)\\n' , // $1: Header Row
'[ ]{0,3}' , // Allowed whitespace
'([-:]+[ ]*[|][-| :]*)\\n' , // $2: Separator
'(' , // $3: Table Body
'(?:.*[|].*\\n?)*' , // Table rows
')' ,
'(?:\\n|$)' // Stop at final newline
].join(''),
'gm'
);
text = text.replace(leadingPipe, doTable);
text = text.replace(noLeadingPipe, doTable);
// $1 = header, $2 = separator, $3 = body
function doTable(match, header, separator, body, offset, string) {
// remove any leading pipes and whitespace
header = header.replace(/^ *[|]/m, '');
separator = separator.replace(/^ *[|]/m, '');
body = body.replace(/^ *[|]/gm, '');
// remove trailing pipes and whitespace
header = header.replace(/[|] *$/m, '');
separator = separator.replace(/[|] *$/m, '');
body = body.replace(/[|] *$/gm, '');
// determine column alignments
var alignspecs = separator.split(/ *[|] */);
var align = [];
for (var i = 0; i < alignspecs.length; i++) {
var spec = alignspecs[i];
if (spec.match(/^ *-+: *$/m))
align[i] = ' align="right"';
else if (spec.match(/^ *:-+: *$/m))
align[i] = ' align="center"';
else if (spec.match(/^ *:-+ *$/m))
align[i] = ' align="left"';
else align[i] = '';
}
// TODO: parse spans in header and rows before splitting, so that pipes
// inside of tags are not interpreted as separators
var headers = header.split(/ *[|] */);
var colCount = headers.length;
// build html
var cls = self.tableClass ? ' class="' + self.tableClass + '"' : '';
var html = ['<table', cls, '>\n', '<thead>\n', '<tr>\n'].join('');
// build column headers.
for (i = 0; i < colCount; i++) {
var headerHtml = convertSpans(trim(headers[i]), self);
html += [" <th", align[i], ">", headerHtml, "</th>\n"].join('');
}
html += "</tr>\n</thead>\n";
// build rows
var rows = body.split('\n');
for (i = 0; i < rows.length; i++) {
if (rows[i].match(/^\s*$/)) // can apply to final row
continue;
// ensure number of rowCells matches colCount
var rowCells = rows[i].split(/ *[|] */);
var lenDiff = colCount - rowCells.length;
for (var j = 0; j < lenDiff; j++)
rowCells.push('');
html += "<tr>\n";
for (j = 0; j < colCount; j++) {
var colHtml = convertSpans(trim(rowCells[j]), self);
html += [" <td", align[j], ">", colHtml, "</td>\n"].join('');
}
html += "</tr>\n";
}
html += "</table>\n";
// replace html with placeholder until postConversion step
return self.hashExtraBlock(html);
}
return text;
};
/******************************************************************
* Footnotes *
*****************************************************************/
// Strip footnote, store in hashes.
Markdown.Extra.prototype.stripFootnoteDefinitions = function(text) {
var self = this;
text = text.replace(
/\n[ ]{0,3}\[\^(.+?)\]\:[ \t]*\n?([\s\S]*?)\n{1,2}((?=\n[ ]{0,3}\S)|$)/g,
function(wholeMatch, m1, m2) {
m1 = slugify(m1);
m2 += "\n";
m2 = m2.replace(/^[ ]{0,3}/g, "");
self.footnotes[m1] = m2;
return "\n";
});
return text;
};
// Find and convert footnotes references.
Markdown.Extra.prototype.doFootnotes = function(text) {
var self = this;
if(self.isConvertingFootnote === true) {
return text;
}
var footnoteCounter = 0;
text = text.replace(/\[\^(.+?)\]/g, function(wholeMatch, m1) {
var id = slugify(m1);
var footnote = self.footnotes[id];
if (footnote === undefined) {
return wholeMatch;
}
footnoteCounter++;
self.usedFootnotes.push(id);
var html = '<a href="#fn:' + id + '" id="fnref:' + id
+ '" title="See footnote" class="footnote">' + footnoteCounter
+ '</a>';
return self.hashExtraInline(html);
});
return text;
};
// Print footnotes at the end of the document
Markdown.Extra.prototype.printFootnotes = function(text) {
var self = this;
if (self.usedFootnotes.length === 0) {
return text;
}
text += '\n\n<div class="footnotes">\n<hr>\n<ol>\n\n';
for(var i=0; i<self.usedFootnotes.length; i++) {
var id = self.usedFootnotes[i];
var footnote = self.footnotes[id];
self.isConvertingFootnote = true;
var formattedfootnote = convertSpans(footnote, self);
delete self.isConvertingFootnote;
text += '<li id="fn:'
+ id
+ '">'
+ formattedfootnote
+ ' <a href="#fnref:'
+ id
+ '" title="Return to article" class="reversefootnote">&#8617;</a></li>\n\n';
}
text += '</ol>\n</div>';
return text;
};
/******************************************************************
* Fenced Code Blocks (gfm) *
******************************************************************/
// Find and convert gfm-inspired fenced code blocks into html.
Markdown.Extra.prototype.fencedCodeBlocks = function(text) {
function encodeCode(code) {
code = code.replace(/&/g, "&amp;");
code = code.replace(/</g, "&lt;");
code = code.replace(/>/g, "&gt;");
// These were escaped by PageDown before postNormalization
code = code.replace(/~D/g, "$$");
code = code.replace(/~T/g, "~");
return code;
}
var self = this;
text = text.replace(/(?:^|\n)```([^`\n]*)\n([\s\S]*?)\n```[ \t]*(?=\n)/g, function(match, m1, m2) {
var language = trim(m1), codeblock = m2;
// adhere to specified options
var preclass = self.googleCodePrettify ? ' class="prettyprint"' : '';
var codeclass = '';
if (language) {
if (self.googleCodePrettify || self.highlightJs) {
// use html5 language- class names. supported by both prettify and highlight.js
codeclass = ' class="language-' + language + '"';
} else {
codeclass = ' class="' + language + '"';
}
}
var html = ['<pre', preclass, '><code', codeclass, '>',
encodeCode(codeblock), '</code></pre>'].join('');
// replace codeblock with placeholder until postConversion step
return self.hashExtraBlock(html);
});
return text;
};
/******************************************************************
* SmartyPants *
******************************************************************/
Markdown.Extra.prototype.educatePants = function(text) {
var self = this;
var result = '';
var blockOffset = 0;
// Here we parse HTML in a very bad manner
text.replace(/(?:<!--[\s\S]*?-->)|(<)([a-zA-Z1-6]+)([^\n]*?>)([\s\S]*?)(<\/\2>)/g, function(wholeMatch, m1, m2, m3, m4, m5, offset) {
var token = text.substring(blockOffset, offset);
result += self.applyPants(token);
self.smartyPantsLastChar = result.substring(result.length - 1);
blockOffset = offset + wholeMatch.length;
if(!m1) {
// Skip commentary
result += wholeMatch;
return;
}
// Skip special tags
if(!/code|kbd|pre|script|noscript|iframe|math|ins|del|pre/i.test(m2)) {
m4 = self.educatePants(m4);
}
else {
self.smartyPantsLastChar = m4.substring(m4.length - 1);
}
result += m1 + m2 + m3 + m4 + m5;
});
var lastToken = text.substring(blockOffset);
result += self.applyPants(lastToken);
self.smartyPantsLastChar = result.substring(result.length - 1);
return result;
};
function revertPants(wholeMatch, m1) {
var blockText = m1;
blockText = blockText.replace(/&\#8220;/g, "\"");
blockText = blockText.replace(/&\#8221;/g, "\"");
blockText = blockText.replace(/&\#8216;/g, "'");
blockText = blockText.replace(/&\#8217;/g, "'");
blockText = blockText.replace(/&\#8212;/g, "---");
blockText = blockText.replace(/&\#8211;/g, "--");
blockText = blockText.replace(/&\#8230;/g, "...");
return blockText;
}
Markdown.Extra.prototype.applyPants = function(text) {
// Dashes
text = text.replace(/---/g, "&#8212;").replace(/--/g, "&#8211;");
// Ellipses
text = text.replace(/\.\.\./g, "&#8230;").replace(/\.\s\.\s\./g, "&#8230;");
// Backticks
text = text.replace(/``/g, "&#8220;").replace (/''/g, "&#8221;");
if(/^'$/.test(text)) {
// Special case: single-character ' token
if(/\S/.test(this.smartyPantsLastChar)) {
return "&#8217;";
}
return "&#8216;";
}
if(/^"$/.test(text)) {
// Special case: single-character " token
if(/\S/.test(this.smartyPantsLastChar)) {
return "&#8221;";
}
return "&#8220;";
}
// Special case if the very first character is a quote
// followed by punctuation at a non-word-break. Close the quotes by brute force:
text = text.replace (/^'(?=[!"#\$\%'()*+,\-.\/:;<=>?\@\[\\]\^_`{|}~]\B)/, "&#8217;");
text = text.replace (/^"(?=[!"#\$\%'()*+,\-.\/:;<=>?\@\[\\]\^_`{|}~]\B)/, "&#8221;");
// Special case for double sets of quotes, e.g.:
// <p>He said, "'Quoted' words in a larger quote."</p>
text = text.replace(/"'(?=\w)/g, "&#8220;&#8216;");
text = text.replace(/'"(?=\w)/g, "&#8216;&#8220;");
// Special case for decade abbreviations (the '80s):
text = text.replace(/'(?=\d{2}s)/g, "&#8217;");
// Get most opening single quotes:
text = text.replace(/(\s|&nbsp;|--|&[mn]dash;|&\#8211;|&\#8212;|&\#x201[34];)'(?=\w)/g, "$1&#8216;");
// Single closing quotes:
text = text.replace(/([^\s\[\{\(\-])'/g, "$1&#8217;");
text = text.replace(/'(?=\s|s\b)/g, "&#8217;");
// Any remaining single quotes should be opening ones:
text = text.replace(/'/g, "&#8216;");
// Get most opening double quotes:
text = text.replace(/(\s|&nbsp;|--|&[mn]dash;|&\#8211;|&\#8212;|&\#x201[34];)"(?=\w)/g, "$1&#8220;");
// Double closing quotes:
text = text.replace(/([^\s\[\{\(\-])"/g, "$1&#8221;");
text = text.replace(/"(?=\s)/g, "&#8221;");
// Any remaining quotes should be opening ones.
text = text.replace(/"/ig, "&#8220;");
return text;
};
// Find and convert markdown extra definition lists into html.
Markdown.Extra.prototype.runSmartyPants = function(text) {
this.smartyPantsLastChar = '';
text = this.educatePants(text);
// Clean everything inside html tags (some of them may have been converted due to our rough html parsing)
text = text.replace(/(<([a-zA-Z1-6]+)\b([^\n>]*?)(\/)?>)/g, revertPants);
return text;
};
/******************************************************************
* Definition Lists *
******************************************************************/
// Find and convert markdown extra definition lists into html.
Markdown.Extra.prototype.definitionLists = function(text) {
var wholeList = new RegExp(
['(\\x02\\n?|\\n\\n)' ,
'(?:' ,
'(' , // $1 = whole list
'(' , // $2
'[ ]{0,3}' ,
'((?:[ \\t]*\\S.*\\n)+)', // $3 = defined term
'\\n?' ,
'[ ]{0,3}:[ ]+' , // colon starting definition
')' ,
'([\\s\\S]+?)' ,
'(' , // $4
'(?=\\0x03)' , // \z
'|' ,
'(?=' ,
'\\n{2,}' ,
'(?=\\S)' ,
'(?!' , // Negative lookahead for another term
'[ ]{0,3}' ,
'(?:\\S.*\\n)+?' , // defined term
'\\n?' ,
'[ ]{0,3}:[ ]+' , // colon starting definition
')' ,
'(?!' , // Negative lookahead for another definition
'[ ]{0,3}:[ ]+' , // colon starting definition
')' ,
')' ,
')' ,
')' ,
')'
].join(''),
'gm'
);
var self = this;
text = addAnchors(text);
text = text.replace(wholeList, function(match, pre, list) {
var result = trim(self.processDefListItems(list));
result = "<dl>\n" + result + "\n</dl>";
return pre + self.hashExtraBlock(result) + "\n\n";
});
return removeAnchors(text);
};
// Process the contents of a single definition list, splitting it
// into individual term and definition list items.
Markdown.Extra.prototype.processDefListItems = function(listStr) {
var self = this;
var dt = new RegExp(
['(\\x02\\n?|\\n\\n+)' , // leading line
'(' , // definition terms = $1
'[ ]{0,3}' , // leading whitespace
'(?![:][ ]|[ ])' , // negative lookahead for a definition
// mark (colon) or more whitespace
'(?:\\S.*\\n)+?' , // actual term (not whitespace)
')' ,
'(?=\\n?[ ]{0,3}:[ ])' // lookahead for following line feed
].join(''), // with a definition mark
'gm'
);
var dd = new RegExp(
['\\n(\\n+)?' , // leading line = $1
'(' , // marker space = $2
'[ ]{0,3}' , // whitespace before colon
'[:][ ]+' , // definition mark (colon)
')' ,
'([\\s\\S]+?)' , // definition text = $3
'(?=\\n*' , // stop at next definition mark,
'(?:' , // next term or end of text
'\\n[ ]{0,3}[:][ ]|' ,
'<dt>|\\x03' , // \z
')' ,
')'
].join(''),
'gm'
);
listStr = addAnchors(listStr);
// trim trailing blank lines:
listStr = listStr.replace(/\n{2,}(?=\\x03)/, "\n");
// Process definition terms.
listStr = listStr.replace(dt, function(match, pre, termsStr) {
var terms = trim(termsStr).split("\n");
var text = '';
for (var i = 0; i < terms.length; i++) {
var term = terms[i];
// process spans inside dt
term = convertSpans(trim(term), self);
text += "\n<dt>" + term + "</dt>";
}
return text + "\n";
});
// Process actual definitions.
listStr = listStr.replace(dd, function(match, leadingLine, markerSpace, def) {
if (leadingLine || def.match(/\n{2,}/)) {
// replace marker with the appropriate whitespace indentation
def = Array(markerSpace.length + 1).join(' ') + def;
// process markdown inside definition
// TODO?: currently doesn't apply extensions
def = outdent(def) + "\n\n";
def = "\n" + convertAll(def, self) + "\n";
} else {
// convert span-level markdown inside definition
def = rtrim(def);
def = convertSpans(outdent(def), self);
}
return "\n<dd>" + def + "</dd>\n";
});
return removeAnchors(listStr);
};
/***********************************************************
* Strikethrough *
************************************************************/
Markdown.Extra.prototype.strikethrough = function(text) {
// Pretty much duplicated from _DoItalicsAndBold
return text.replace(/([\W_]|^)~T~T(?=\S)([^\r]*?\S[\*_]*)~T~T([\W_]|$)/g,
"$1<del>$2</del>$3");
};
/***********************************************************
* New lines *
************************************************************/
Markdown.Extra.prototype.newlines = function(text) {
// We have to ignore already converted newlines and line breaks in sub-list items
return text.replace(/(<(?:br|\/li)>)?\n/g, function(wholeMatch, previousTag) {
return previousTag ? wholeMatch : " <br>\n";
});
};
})();

View File

@ -64,4 +64,13 @@
return this;
};
// jQuery's show() sets display as 'inline', this utility sets it to whatever we want.
// Useful for buttons or links that need 'inline-block' or flex for correct padding and alignment.
$.fn.displayAs = function(display_type) {
if (typeof(display_type) === 'undefined') {
display_type = 'block';
}
this.css('display', display_type);
}
}(jQuery));

View File

@ -32,7 +32,7 @@ var DocumentTitleAPI = {
};
/* Status Bar */
/* Status Bar * DEPRECATED * USE TOASTR INSTEAD */
function statusBarClear(delay_class, delay_html){
var statusBar = $("#status-bar");
@ -54,6 +54,7 @@ function statusBarClear(delay_class, delay_html){
}
}
/* Status Bar * DEPRECATED - USE TOASTR INSTEAD * */
function statusBarSet(classes, html, icon_name, time){
/* Utility to notify the user by temporarily flashing text on the project header
Usage:

View File

@ -66,12 +66,9 @@ function containerResizeY(window_height){
var project_container = document.getElementById('project-container');
var container_offset = project_container.offsetTop;
var nav_header_height = $('#project_nav-header').height();
var container_height = window_height - container_offset.top;
var container_height_wheader = window_height - container_offset.top - nav_header_height;
var window_height_minus_nav = window_height - nav_header_height - 1; // 1 is border width
$('#project_context-header').width($('#project_context-container').width());
var container_height_wheader = window_height - container_offset;
var window_height_minus_nav = window_height - container_offset;
if ($(window).width() > 768) {
$('#project-container').css(
@ -79,13 +76,14 @@ function containerResizeY(window_height){
'height': window_height_minus_nav + 'px'}
);
$('#project_nav-container, #project_tree, .project_split').css(
{'max-height': (window_height_minus_nav - 50) + 'px',
'height': (window_height_minus_nav - 50) + 'px'}
$('#project_nav-container, #project_tree').css(
{'max-height': (window_height_minus_nav) + 'px',
'height': (window_height_minus_nav) + 'px'}
);
if (container_height > parseInt($('#project-container').css("min-height"))) {
if (typeof projectTree !== "undefined"){
$(projectTree).css(
{'max-height': container_height_wheader + 'px',
'height': container_height_wheader + 'px'}

View File

@ -40,11 +40,6 @@ $(document).on('click','body .comment-action-reply',function(e){
parentDiv.after(commentForm);
// document.getElementById('comment_field').focus();
$(commentField).focus();
// Convert Markdown
var convert = new Markdown.getSanitizingConverter().makeHtml;
var preview = $('.comment-reply-preview-md');
preview.html(convert($(commentField).val()));
$('.comment-reply-field').addClass('filled');
});
@ -59,10 +54,6 @@ $(document).on('click','body .comment-action-cancel',function(e){
delete commentField.dataset.originalParentId;
$(commentField).val('');
// Convert Markdown
var convert = new Markdown.getSanitizingConverter().makeHtml;
var preview = $('.comment-reply-preview-md');
preview.html(convert($(commentField).val()));
$('.comment-reply-field').removeClass('filled');
$('.comment-container').removeClass('is-replying');

View File

@ -0,0 +1,202 @@
/* Video.JS plugin for keeping track of user's viewing progress.
Also registers the analytics plugin.
Progress is reported after a number of seconds or a percentage
of the duration of the video, whichever comes first.
Example usage:
videojs(videoPlayerElement, options).ready(function() {
let report_url = '{{ url_for("users_api.set_video_progress", video_id=node._id) }}';
this.progressPlugin({'report_url': report_url});
});
*/
// Report after progressing this many seconds video-time.
let PROGRESS_REPORT_INTERVAL_SEC = 30;
// Report after progressing this percentage of the entire video (scale 0-100).
let PROGRESS_REPORT_INTERVAL_PERC = 10;
// Don't report within this many milliseconds of wall-clock time of the previous report.
let PROGRESS_RELAXING_TIME_MSEC = 500;
var Plugin = videojs.getPlugin('plugin');
var VideoProgressPlugin = videojs.extend(Plugin, {
constructor: function(player, options) {
Plugin.call(this, player, options);
this.last_wallclock_time_ms = 0;
this.last_inspected_progress_in_sec = 0;
this.last_reported_progress_in_sec = 0;
this.last_reported_progress_in_perc = 0;
this.report_url = options.report_url;
this.fetch_progress_url = options.fetch_progress_url;
this.reported_error = false;
this.reported_looping = false;
if (typeof this.report_url === 'undefined' || !this.report_url) {
/* If we can't report anything, don't bother registering event handlers. */
videojs.log('VideoProgressPlugin: no report_url option given. Not storing video progress.');
} else {
/* Those events will have 'this' bound to the player,
* which is why we explicitly re-bind to 'this''. */
player.on('timeupdate', this.on_timeupdate.bind(this));
player.on('pause', this.on_pause.bind(this));
}
if (typeof this.fetch_progress_url === 'undefined' || !this.fetch_progress_url) {
/* If we can't report anything, don't bother registering event handlers. */
videojs.log('VideoProgressPlugin: no fetch_progress_url option given. Not restoring video progress.');
} else {
this.resume_playback();
}
},
resume_playback: function() {
let on_done = function(progress, status, xhr) {
/* 'progress' is an object like:
{"progress_in_sec": 3,
"progress_in_percent": 51,
"last_watched": "Fri, 31 Aug 2018 13:53:06 GMT",
"done": true}
*/
switch (xhr.status) {
case 204: return; // no info found.
case 200:
/* Don't do anything when the progress is at 100%.
* Moving the current time to the end makes no sense then. */
if (progress.progress_in_percent >= 100) return;
/* Set the 'last reported' props before manipulating the
* player, so that the manipulation doesn't trigger more
* API calls to remember what we just restored. */
this.last_reported_progress_in_sec = progress.progress_in_sec;
this.last_reported_progress_in_perc = progress.progress_in_perc;
console.log("Continuing playback at ", progress.progress_in_percent, "% from", progress.last_watched);
this.player.currentTime(progress.progress_in_sec);
this.player.play();
return;
default:
console.log("Unknown code", xhr.status, "getting video progress information.");
}
};
$.get(this.fetch_progress_url)
.fail(function(error) {
console.log("Unable to fetch video progress information:", xhrErrorResponseMessage(error));
})
.done(on_done.bind(this));
},
/* Pausing playback should report the progress.
* This function is also called when playback stops at the end of the video,
* so it's important to report in this case; otherwise progress will never
* reach 100%. */
on_pause: function(event) {
this.inspect_progress(true);
},
on_timeupdate: function() {
this.inspect_progress(false);
},
inspect_progress: function(force_report) {
// Don't report seeking when paused, only report actual playback.
if (!force_report && this.player.paused()) return;
let now_in_ms = new Date().getTime();
if (!force_report && now_in_ms - this.last_wallclock_time_ms < PROGRESS_RELAXING_TIME_MSEC) {
// We're trying too fast, don't bother doing any other calculation.
// console.log('skipping, already reported', now_in_ms - this.last_wallclock_time_ms, 'ms ago.');
return;
}
let progress_in_sec = this.player.currentTime();
let duration_in_sec = this.player.duration();
/* Instead of reporting the current time, report reaching the end
* of the video. This ensures that it's properly marked as 'done'. */
if (!this.reported_looping) {
let margin = 1.25 * PROGRESS_RELAXING_TIME_MSEC / 1000.0;
let is_looping = progress_in_sec == 0 && duration_in_sec - this.last_inspected_progress_in_sec < margin;
this.last_inspected_progress_in_sec = progress_in_sec;
if (is_looping) {
this.reported_looping = true;
this.report(this.player.duration(), 100, now_in_ms);
return;
}
}
if (Math.abs(progress_in_sec - this.last_reported_progress_in_sec) < 0.01) {
// Already reported this, don't bother doing it again.
return;
}
let progress_in_perc = 100 * progress_in_sec / duration_in_sec;
let diff_sec = progress_in_sec - this.last_reported_progress_in_sec;
let diff_perc = progress_in_perc - this.last_reported_progress_in_perc;
if (!force_report
&& Math.abs(diff_perc) < PROGRESS_REPORT_INTERVAL_PERC
&& Math.abs(diff_sec) < PROGRESS_REPORT_INTERVAL_SEC) {
return;
}
this.report(progress_in_sec, progress_in_perc, now_in_ms);
},
report: function(progress_in_sec, progress_in_perc, now_in_ms) {
/* Store when we tried, not when we succeeded. This function can be
* called every 15-250 milliseconds, so we don't want to retry with
* that frequency. */
this.last_wallclock_time_ms = now_in_ms;
let on_fail = function(error) {
/* Don't show (as in: a toastr popup) the error to the user,
* as it doesn't impact their ability to play the video.
* Also show the error only once, instead of spamming. */
if (this.reported_error) return;
let msg = xhrErrorResponseMessage(error);
console.log('Unable to report viewing progress:', msg);
this.reported_error = true;
};
let on_done = function() {
this.last_reported_progress_in_sec = progress_in_sec;
this.last_reported_progress_in_perc = progress_in_perc;
};
$.post(this.report_url, {
progress_in_sec: progress_in_sec,
progress_in_perc: Math.round(progress_in_perc),
})
.fail(on_fail.bind(this))
.done(on_done.bind(this));
},
});
var RememberVolumePlugin = videojs.extend(Plugin, {
constructor: function(player, options) {
Plugin.call(this, player, options);
player.on('volumechange', this.on_volumechange.bind(this));
this.restore_volume();
},
restore_volume: function() {
let volume_str = localStorage.getItem('video-player-volume');
if (volume_str == null) return;
this.player.volume(1.0 * volume_str);
},
on_volumechange: function(event) {
localStorage.setItem('video-player-volume', this.player.volume());
},
});
// Register our watch-progress-bookkeeping plugin.
videojs.registerPlugin('progressPlugin', VideoProgressPlugin);
videojs.registerPlugin('rememberVolumePlugin', RememberVolumePlugin);

View File

@ -143,12 +143,17 @@ nav.sidebar
left: 0
width: $sidebar-width
height: 100%
background-color: $color-background-nav
display: flex
flex-direction: column
> ul > li > .navbar-item
padding-top: 10px
padding-bottom: 10px
background: red
.dropdown
min-width: $sidebar-width
.dropdown-menu
top: initial
bottom: 3px
@ -159,7 +164,7 @@ nav.sidebar
li a
justify-content: flex-start
ul
> ul
width: 100%
margin: 0
padding: 0
@ -172,25 +177,11 @@ nav.sidebar
a.navbar-item, button
display: flex
color: $color-text-light-hint
font-size: 1.5em
align-items: center
justify-content: center
padding: 10px 0
background: transparent
border: none
width: 100%
text-decoration: none
&:hover
color: $color-text-light-primary
&:active
outline: none
&.cloud
i
position: relative
left: -4px
a.dropdown-toggle
padding: 0
@ -408,3 +399,68 @@ nav.sidebar
top: -1px
left: -19px
z-index: 1
$loader-bar-width: 100px
$loader-bar-height: 2px
.loader-bar
bottom: -$loader-bar-height
content: ''
display: none
height: 0
overflow: hidden
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
left: -$loader-bar-width
position: absolute
width: $loader-bar-width
&.active
display: block
height: $loader-bar-height
visibility: visible
&:before
animation: loader-bar-slide 2s linear infinite
@keyframes loader-bar-slide
from
left: -($loader-bar-width / 2)
width: 3%
50%
width: 20%
70%
width: 70%
80%
left: 50%
95%
left: 120%
to
left: 100%
.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
@ -30,7 +32,7 @@ $comments-width-max: 710px
.comment-reply-container
display: flex
position: relative
padding: 15px 0 20px 0
padding: 15px 0
transition: background-color 150ms ease-in-out, padding 150ms ease-in-out, margin 150ms ease-in-out
&.comment-linked
@ -194,8 +196,6 @@ $comments-width-max: 710px
cursor: pointer
font-family: 'pillar-font'
height: 25px
position: relative
top: 4px
width: 16px
.comment-action-rating.up
@ -284,10 +284,11 @@ $comments-width-max: 710px
color: $color-success
&.is-replying
box-shadow: inset 5px 0 0 $color-primary
box-shadow: -5px 0 0 $color-primary
@extend .pl-3
&.is-replying+.comment-reply-container
box-shadow: inset 5px 0 0 $color-primary
box-shadow: -5px 0 0 $color-primary
margin-left: 0
padding-left: 55px
@ -314,9 +315,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 +331,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 +340,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 +375,6 @@ $comments-width-max: 710px
&.filled
textarea
background-color: $color-background-light
border-bottom: thin solid $color-background
&:focus
@ -453,12 +451,17 @@ $comments-width-max: 710px
transition: background-color 150ms ease-in-out, color 150ms ease-in-out
width: 100px
// The actual button for submitting the comment.
button.comment-action-submit
align-items: center
background: transparent
border: none
border-top-left-radius: 0
border-bottom-left-radius: 0
color: $color-success
cursor: pointer
display: flex
justify-content: center
flex-direction: column
height: 100%
position: relative
@ -466,8 +469,12 @@ $comments-width-max: 710px
white-space: nowrap
width: 100%
&:hover
background: rgba($color-success, .1)
&:focus
background: lighten($color-success, 10%)
color: $white
&.submitting
color: $color-info

View File

@ -12,9 +12,10 @@ $color-background-active-dark: hsl(hue($color-background-active), 50%, 50%) !def
$font-body: 'Roboto' !default
$font-headings: 'Lato' !default
$font-size: 14px !default
$font-size-xs: .75rem
$font-size-xxs: .65rem
$color-text: #4d4e53 !default
$color-text-dark: $color-text !default
$color-text-dark-primary: #646469 !default
$color-text-dark-secondary: #9E9FA2 !default
@ -25,10 +26,11 @@ $color-text-light-primary: rgba($color-text-light, .87) !default
$color-text-light-secondary: rgba($color-text-light, .54) !default
$color-text-light-hint: rgba($color-text-light, .38) !default
$color-primary: #68B3C8 !default
$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
@ -96,16 +98,17 @@ $screen-xs-max: $screen-sm-min - 1 !default
$screen-sm-max: $screen-md-min - 1 !default
$screen-md-max: $screen-lg-min - 1 !default
$sidebar-width: 50px !default
$sidebar-width: 40px !default
/* Project specifics */
$project_nav-width: 250px !default
$project-sidebar-width: 50px !default
$project_header-height: 50px !default
$project_footer-height: 30px !default
$navbar-height: 50px !default
$navbar-backdrop-height: 600px !default
$project_nav-width-xl: $project_nav-width * 1.4 !default
$project_nav-width-lg: $project_nav-width * 1.2 !default
$project_nav-width-md: $project_nav-width
$project_nav-width-sm: $project_nav-width * 0.8 !default
$project_nav-width-xs: 100% !default
$project-sidebar-width: 40px !default
$project_header-height: 40px !default
$node-type-asset_image: #e87d86 !default
$node-type-asset_file: #CC91C7 !default
@ -125,3 +128,39 @@ $z-index-base: 13 !default
@media (min-width: $screen-lg-min)
width: 1270px
// Bootstrap overrides.
$enable-caret: false
$border-radius: .2rem
$btn-border-radius: $border-radius
$primary: $color-primary
$body-bg: $white
$body-color: $color-text
$color-background-nav: #fff
$link-color: $primary
$font-family-sans-serif: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, Helvetica, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"
$font-size-base: .9rem
$dropdown-border-width: 0
$dropdown-box-shadow: 0 10px 25px rgba($black, .1)
$dropdown-padding-y: 0
$dropdown-item-padding-y: .4rem
// Tooltips.
$tooltip-font-size: 0.83rem
$tooltip-max-width: auto
$tooltip-opacity: 1
$nav-link-height: 37px
$navbar-padding-x: 0
$navbar-padding-y: 0
$btn-padding-y-sm: 0.1rem
$grid-breakpoints: (xs: 0,sm: 576px,md: 768px,lg: 1060px,xl: 1500px, xxl: 1800px)

View File

@ -60,14 +60,13 @@
#node-overlay
#error-container
position: fixed
top: $navbar-height
align-items: flex-start
position: fixed
top: $nav-link-height
#error-box
box-shadow: 0 0 25px rgba(black, .1), 0 0 50px rgba(black, .1)
width: auto
border-top-left-radius: 0
border-top-right-radius: 0
box-shadow: 0 0 25px rgba(black, .1), 0 0 50px rgba(black, .1)
position: relative
width: 100%

View File

@ -9,7 +9,6 @@
color: $color-primary
cursor: pointer
float: right
font-family: $font-body
height: initial
margin: 0
padding: 8px 10px 0 10px
@ -25,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
@ -46,10 +48,10 @@
border-color: transparent transparent $color-background transparent
border-style: solid
border-width: 0 8px 8px 8px
bottom: -15px
bottom: -10px
height: 0
position: absolute
right: 22px
right: 7px
visibility: hidden
width: 0

View File

@ -1,20 +1,4 @@
body.organizations
ul#sub-nav-tabs__list
align-items: center
display: flex
li.result
padding: 10px 20px
li.create
margin-left: auto
.dashboard-secondary
.box
+container-box
padding: 10px 20px
margin: 0
#item-details
.organization
label

View File

@ -409,7 +409,6 @@ a.page-card-cta
display: block
+position-center-translate
+media-xs
display: none
+media-sm
@ -419,9 +418,6 @@ a.page-card-cta
+media-lg
width: 100%
.services.navbar-backdrop-overlay
background: rgba(black, .5)
.services
.page-card-side
max-width: 500px

View File

@ -1,22 +1,16 @@
.dashboard-container
section#home,
section#projects
background-color: $color-background
border-bottom-left-radius: 3px
border-bottom-right-radius: 3px
nav#sub-nav-tabs.home,
nav#sub-nav-tabs.projects
background-color: white
border-bottom: thin solid $color-background-dark
li.nav-tabs__list-tab
padding: 15px 20px 10px 20px
section#home
background-color: $color-background-dark
nav.nav-tabs__tab
display: none
background-color: $color-background-light
@ -287,9 +281,8 @@
flex-direction: column
.title
font-size: 1.2em
padding-bottom: 2px
color: $color-text-dark-primary
padding-bottom: 2px
ul.meta
font-size: .9em

View File

@ -92,19 +92,6 @@ ul.sharing-users-list
&:hover
color: lighten($color-danger, 10%)
.sharing-users-intro,
.sharing-users-info
h4
font-family: $font-body
.sharing-users-info
padding-left: 15px
border-left: thin solid $color-text-dark-hint
p
font:
size: 1.1em
weight: 300
.sharing-users-search
.disabled
@ -162,24 +149,26 @@ ul.list-generic
list-style: none
> li
padding: 5px 0
display: flex
align-items: center
border-top: thin solid $color-background
display: flex
padding: 5px 0
&:first-child
border-top: none
&:hover .item a
color: $color-primary
color: $primary
a
flex: 1
&.active
color: $primary !important
font-weight: bold
.actions
margin-left: auto
.btn
font-size: .7em
span
color: $color-text-dark-secondary

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,7 @@ $search-hit-width_grid: 100px
.search-hit-name
font-weight: 400
padding-top: 8px
color: $color-primary-dark
color: $primary
.search-hit
padding: 0
@ -29,14 +29,13 @@ $search-hit-width_grid: 100px
font:
size: .9em
weight: 400
family: $font-body
style: initial
width: 100%
+text-overflow-ellipsis
+clearfix
& em
color: $color-primary-dark
color: $primary
font-style: normal
&:hover
@ -71,7 +70,7 @@ $search-hit-width_grid: 100px
min-width: 350px
border-bottom-left-radius: 3px
border-bottom-right-radius: 3px
border-top: 3px solid lighten($color-primary, 5%)
border-top: 3px solid lighten($primary, 5%)
overflow: hidden
.tt-suggestion
@ -93,235 +92,68 @@ $search-hit-width_grid: 100px
&.tt-cursor:hover .search-hit
background-color: lighten($color-background, 5%)
#search-container
display: flex
min-height: 600px
background-color: white
.search-list
width: 50%
+media-lg
padding-left: 0
padding-right: 0
.embed-responsive
width: 100px
min-width: 100px
#search-sidebar
width: 20%
background-color: $color-background-light
.card-deck.card-deck-vertical
.card
flex-wrap: initial
+media-lg
border-top-left-radius: 3px
.search-settings
width: 30%
input.search-field
background-color: $color-background-nav-dark
font-size: 1.1em
color: white
margin-bottom: 10px
.card-deck.card-deck-vertical
.card .embed-responsive
max-width: 80px
input.search-field
border: none
border-bottom: 2px solid rgba($primary, .2)
border-radius: 0
width: 100%
transition: border 100ms ease-in-out
&::placeholder
color: $color-text-dark-secondary
&:placeholder-shown
border-bottom-color: $primary
&:focus
outline: none
border: none
border-bottom: 2px solid rgba($color-primary, .2)
border-radius: 0
width: 100%
padding: 5px 15px
height: 50px
transition: border 100ms ease-in-out
border-bottom: 2px solid lighten($primary, 5%)
&::placeholder
color: $color-text-dark-secondary
&:placeholder-shown
border-bottom-color: $color-primary
.search-details
width: 70%
&:focus
outline: none
border: none
border-bottom: 2px solid lighten($color-primary, 5%)
.container-fluid .col-md-8
flex: 1
max-width: 100%
width: 100%
.search-list-filters
padding:
left: 10px
right: 10px
#search-details
position: relative
.panel.panel-default
margin-bottom: 10px
border-radius: 3px
border: none
background-color: white
box-shadow: 1px 1px 0 rgba(black, .1)
a
text-decoration: none
.toggleRefine
display: block
padding-left: 7px
color: $color-text-dark
text-transform: capitalize
&:hover
text-decoration: none
color: $color-primary
&.refined
color: $color-primary
&:hover
color: $color-danger
span
&:before
/* x icon */
content: '\e84b'
font-family: 'pillar-font'
span
&:before
/* circle with dot */
content: '\e82f'
font-family: 'pillar-font'
position: relative
left: -7px
font-size: .9em
span
&:before
/* empty circle */
content: '\e82c'
font-family: 'pillar-font'
position: relative
left: -7px
font-size: .9em
.facet_count
color: $color-text-dark-secondary
.panel-title, .panel-heading
color: $color-text-dark-secondary
font:
size: 1em
weight: 500
.panel-body
padding-top: 0
.panel-title
position: relative
&:after
content: '\e83b'
font-family: 'pillar-font'
position: absolute
right: 0
color: $color-text-dark-primary
.collapsed
.panel-title:after
content: '\e838'
.search-list-stats
color: $color-text-dark-hint
padding: 10px 15px 0 15px
text-align: center
font-size: .9em
+clearfix
#pagination
ul.search-pagination
text-align: center
list-style-type: none
margin: 0
padding: 0
width: 100%
display: flex
+clearfix
li
display: inline-block
margin: 5px auto
&:last-child
border-color: transparent
a
font-weight: 500
padding: 5px 4px
color: $color-text-dark-secondary
&:hover
color: $color-text-dark-primary
&.disabled
opacity: .6
&.active a
color: $color-text-dark-primary
font-weight: bold
#search-list
width: 40%
height: 100%
padding: 0
position: relative
overflow-x: hidden
#search-hit-container
position: absolute // for scrollbars
overflow-y: auto
#hits
#error_container
position: relative
width: 100%
background: white
padding: 20px
#no-hits
padding: 10px 15px
color: $color-text-dark-secondary
.search-hit
#search-loading
visibility: hidden
background-color: transparent
font:
size: 1.5em
weight: 600
position: absolute
top: 0
left: 0
right: 0
bottom: 0
z-index: $z-index-base + 5
opacity: 0
cursor: default
transition: opacity 50ms ease-in-out
&.active
visibility: visible
opacity: 1
.spinner
color: $color-background-nav
background-color: white
padding: 0
width: 20px
height: 20px
border-radius: 50%
position: absolute
top: 7px
right: 10px
span
padding: 5px
+pulse
#search-details
position: relative
width: 40%
border-left: 2px solid darken(white, 3%)
#search-hit-container
position: absolute // for scrollbars
width: 100%
overflow-y: auto
#error_container
position: relative
background: white
padding: 20px
#search-error
display: none
margin: 20px auto
color: $color-danger
text-align: center
#search-error
display: none
margin: 20px auto
color: $color-danger
text-align: center
#search-container
#node-container
width: 100%
max-width: 100%
@ -416,9 +248,7 @@ $search-hit-width_grid: 100px
&.texture
.texture-title
font:
size: 2em
family: $font-body
font-size: 2em
padding: 15px 10px 10px 15px
.node-row
background: white
@ -476,215 +306,88 @@ $search-hit-width_grid: 100px
button
width: 100%
.search-hit
float: left
box-shadow: none
border: thin solid transparent
border-top-color: darken(white, 8%)
border-left: 3px solid transparent
.search-project
li.project
display: none
color: $color-background-nav
width: 100%
position: relative
margin: 0
padding: 7px 10px 7px 10px
+clearfix
&:first-child
border: thin solid transparent
border-left: 3px solid transparent
&:hover
opacity: 1
text-decoration: none
cursor: default
color: darken($color-primary, 20%)
background-color: $color-background-light
& .search-hit-name i
color: darken($color-primary, 20%)
& .search-hit-thumbnail
& .search-hit-thumbnail-icon
transform: translate(-50%, -50%) scale(1.1)
.search-hit-name
text-decoration: none
&:hover
color: darken($color-primary, 10%)
.search-hit-thumbnail
cursor: pointer
.search-hit-thumbnail-icon
transform: translate(-50%, -50%) scale(1)
&:active
background-color: rgba($color-background, .5)
opacity: .8
color: $color-primary
& .search-hit-name i
color: $color-primary
&:focus
border-color: rgba($color-primary, .2)
/* Class that gets added when we click on the item */
&.active
background-color: lighten($color-background, 2%)
border-left: 3px solid $color-primary
.search-hit-name
color: darken($color-primary, 10%)
.search-hit-meta
span.when
display: none
span.context
display: inline-block
.search-hit-thumbnail
position: relative
float: left
min-width: $search-hit-width_list * 1.49
max-width: $search-hit-width_list * 1.49
height: $search-hit-width_list
border-radius: 3px
background: $color-background
margin-right: 12px
text-align: center
overflow: hidden
+media-xs
display: none
+media-sm
min-width: $search-hit-width_list
max-width: $search-hit-width_list
img
height: $search-hit-width_list
width: auto
.pi-video:before, .pi-file:before,
.pi-group:before
font-family: 'pillar-font'
.pi-video:before
content: '\e81d'
.pi-file:before
content: '\e825'
.pi-group:before
content: '\e80d'
.search-hit-thumbnail-icon
position: absolute
top: 50%
left: 50%
transform: translate(-50%, -50%)
color: white
font-size: 1.2em
transition: none
color: $color-text-dark-secondary
.dark
text-shadow: none
font-size: 1.3em
.search-hit-name
position: relative
font-size: 1.1em
color: $color-text-dark-primary
background-color: initial
width: initial
max-width: initial
+text-overflow-ellipsis
padding-top: 5px
#search-sidebar
.toggleRefine
display: block
padding-left: 7px
color: $color-text-dark
text-transform: capitalize
&:hover
cursor: pointer
text-decoration: underline
text-decoration: none
color: $primary
em
color: darken($color-primary, 15%)
font-style: normal
&.refined
color: $primary
.search-hit-ribbon
+ribbon
right: -30px
top: 5px
&:hover
color: $color-danger
span
&:before
/* x icon */
content: '\e84b'
font-family: 'pillar-font'
span
&:before
/* circle with dot */
content: '\e82f'
font-family: 'pillar-font'
position: relative
left: -7px
font-size: .9em
span
font-size: 60%
margin: 1px 0
padding: 2px 35px
&:before
/* empty circle */
content: '\e82c'
font-family: 'pillar-font'
position: relative
left: -7px
font-size: .9em
.search-hit-meta
position: relative
.search-list-stats
color: $color-text-dark-hint
padding: 10px 15px 0 15px
text-align: center
font-size: .9em
color: $color-text-dark-secondary
background-color: initial
padding: 3px 0 0 0
text-decoration: none
+text-overflow-ellipsis
+clearfix
span
&.project
color: $color-text-dark-secondary
margin-right: 3px
&.updated
color: $color-text-dark-hint
&.status
font-size: .8em
color: $color-text-dark-secondary
border: thin solid $color-text-dark-hint
padding: 3px 8px
text-transform: uppercase
border-radius: 3px
margin-right: 5px
&.media, &.node_type
color: $color-text-dark-secondary
text-transform: capitalize
margin: 0 3px
.search-pagination
text-align: center
list-style-type: none
margin: 0
padding: 0
width: 100%
display: flex
+clearfix
&.when
margin: 0 3px
float: right
display: block
+media-lg
display: block
+media-md
display: block
+media-sm
display: none
+media-xs
display: none
li
display: inline-block
margin: 5px auto
&.context
margin: 0
float: right
display: none
&:last-child
border-color: transparent
&:hover
cursor: pointer
.search-hit-name-user
color: $color-primary
&.users
em
font-style: normal
color: $color-primary
.search-hit-name
font-size: 1.2em
small
margin-left: 5px
color: $color-text-dark-secondary
.search-hit-roles
font-size: .9em
a
font-weight: 500
padding: 5px 4px
color: $color-text-dark-secondary
margin-left: 15px
&:hover
color: $color-text-dark-primary
&.disabled
opacity: .6
&.active a
color: $color-text-dark-primary
font-weight: bold
.view-grid
display: flex
@ -706,13 +409,13 @@ $search-hit-width_grid: 100px
transition: border-color 150ms ease-in-out
&.active
background-color: $color-primary
border-color: $color-primary
background-color: $primary
border-color: $primary
.search-hit-name
font-weight: 500
color: white
background-color: $color-primary
background-color: $primary
.search-hit-name
font-size: .9em
@ -776,5 +479,5 @@ $search-hit-width_grid: 100px
&.active
color: white
background-color: $color-primary
background-color: $primary
border-color: transparent

View File

@ -67,138 +67,6 @@
&:hover
background-color: lighten($provider-color-google, 7%)
#settings
+media-xs
flex-direction: column
align-items: stretch
display: flex
margin: 25px auto
#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

@ -26,7 +26,6 @@
display: inline-flex
align-items: center
justify-content: center
font-family: $font-body
padding: 5px 12px
border-radius: $roundness
@ -83,6 +82,15 @@
text-shadow: none
=disabled-stripes
color: $color-text-dark
cursor: not-allowed
background: repeating-linear-gradient(-45deg, lighten($color-text-dark-hint, 15%), lighten($color-text-dark-hint, 15%) 10px, lighten($color-text-dark-hint, 5%) 10px, lighten($color-text-dark-hint, 5%) 20px)
border-color: darken($color-text-dark-hint, 5%)
pointer-events: none
opacity: .6
@mixin overlay($from-color, $from-percentage, $to-color, $to-percentage)
position: absolute
top: 0
@ -122,24 +130,16 @@
transform: translate(-50%, -50%)
=input-generic
padding: 5px 5px 5px 0
color: $color-text-dark
box-shadow: none
font-family: $font-body
border: thin solid transparent
border-radius: 0
border-bottom-color: $color-background-dark
background-color: transparent
transition: border-color 150ms ease-in-out, box-shadow 150ms ease-in-out
&:hover
border-bottom-color: $color-background
&:focus
outline: 0
border: thin solid transparent
border-bottom-color: $color-primary
box-shadow: 0 1px 0 0 $color-primary
border-color: $primary
box-shadow: none
=label-generic
color: $color-text-dark-primary
@ -170,17 +170,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
@ -352,17 +360,15 @@
=node-details-description
+clearfix
color: darken($color-text-dark, 5%)
font:
family: $font-body
weight: 300
size: 1.2em
color: $color-text
font-size: 1.25em
word-break: break-word
+media-xs
font-size: 1.1em
/* Style links without a class. Usually regular
* links in a comment or node description. */
a:not([class])
color: $color-text-dark-primary
text-decoration: underline
@ -375,11 +381,6 @@
line-height: 1.5em
word-wrap: break-word
h1, h2, h3, h4, h5, h6
padding:
top: 20px
right: 20px
blockquote
background-color: lighten($color-background-light, 5%)
box-shadow: inset 5px 0 0 $color-background
@ -400,10 +401,10 @@
img,
p img,
ul li img
@extend .d-block
@extend .mx-auto
@extend .my-3
max-width: 100%
padding:
bottom: 25px
top: 25px
&.emoji
display: inline-block
@ -416,25 +417,13 @@
font-size: 1.5em
/* e.g. YouTube embed */
iframe
height: auto
margin: 15px auto
iframe, video
max-width: 100%
min-height: 500px
width: 100%
@extend .mx-auto
+media-sm
iframe
min-height: 314px
+media-xs
iframe
min-height: 314px
iframe[src^="https://www.youtube"]
+media-xs
iframe
min-height: 420px
min-height: 500px
.embed-responsive,
video
@extend .my-3
iframe[src^="https://w.soundcloud"]
min-height: auto
@ -507,28 +496,24 @@
=ribbon
background-color: $color-success
cursor: default
border: thin dashed rgba(white, .5)
color: white
pointer-events: none
font-size: 70%
overflow: hidden
white-space: nowrap
position: absolute
right: -40px
top: 10px
-webkit-transform: rotate(45deg)
-moz-transform: rotate(45deg)
-ms-transform: rotate(45deg)
-o-transform: rotate(45deg)
transform: rotate(45deg)
white-space: nowrap
span
border: thin dashed rgba(white, .5)
color: white
display: block
font-size: 70%
margin: 1px 0
padding: 3px 50px
text:
align: center
transform: uppercase
.ribbon
+ribbon
@mixin text-background($text-color, $background-color, $roundness, $padding)
border-radius: $roundness
@ -568,9 +553,7 @@
/* Bootstrap's img-responsive class */
=img-responsive
display: block
max-width: 100%
height: auto
@extend .img-fluid
/* Set the color for a specified property
* 1: $property: e.g. background-color
@ -642,9 +625,7 @@
#{$property}: $color-status-review
=sidebar-button-active
background-color: $color-background-nav
box-shadow: inset 2px 0 0 $color-primary
color: white
color: $primary
.flash-on
background-color: lighten($color-success, 50%) !important
@ -660,3 +641,52 @@
transition: all 1s ease-out
img
transition: all 1s ease-out
.cursor-pointer
cursor: pointer
.cursor-zoom-in
cursor: zoom-in
.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.
.imgs-fluid
img
// Just re-use Bootstrap's mixin here.
+img-fluid
.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)
.title-underline
padding-bottom: 5px
position: relative
margin-bottom: 20px
&:before
background-color: $primary
content: ' '
display: block
height: 2px
top: 125%
position: absolute
width: 50px

File diff suppressed because it is too large Load Diff

View File

@ -1,33 +1,58 @@
@import _normalize
// 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/buttons"
@import "../../node_modules/bootstrap/scss/dropdown"
@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/jumbotron"
@import "../../node_modules/bootstrap/scss/media"
@import "../../node_modules/bootstrap/scss/close"
@import "../../node_modules/bootstrap/scss/modal"
@import "../../node_modules/bootstrap/scss/tooltip"
@import "../../node_modules/bootstrap/scss/utilities"
// Pillar components.
@import "apps_base"
@import "components/base"
@import "components/card"
@import "components/jumbotron"
@import "components/navbar"
@import "components/dropdown"
@import "components/footer"
@import "components/shortcode"
@import "components/flyout"
@import "components/buttons"
@import "components/tooltip"
@import "components/overlay"
@import _comments
@import _error
@import _search
.container-fluid.blog
padding: 0
#blog_container
+media-xs
flex-direction: column
padding-top: 0
display: flex
padding:
bottom: 15px
video
max-width: 100%
@import _notifications
#blog_post-edit-form
padding: 20px
.form-group
position: relative
margin: 0 auto 30px auto
font-family: $font-body
input, textarea, select
+input-generic
@ -95,7 +120,6 @@
margin-bottom: 15px
border-top: thin solid $color-text-dark-hint
.form-group.description,
.form-group.summary,
.form-group.content
@ -163,14 +187,10 @@
color: transparent
#blog_post-create-container,
#blog_post-edit-container
padding: 25px
#blog_index-container,
#blog_post-create-container,
#blog_post-edit-container
+container-box
padding: 25px
width: 75%
+media-xs
@ -185,133 +205,6 @@
+media-lg
width: 100%
.blog_index-header
border-top-left-radius: 3px
border-top-right-radius: 3px
display: block
overflow: hidden
position: relative
text-align: center
width: 100%
img
width: 100%
.blog_index-item
+media-lg
max-width: 780px
+media-md
max-width: 780px
+media-sm
max-width: 780px
margin: 15px auto
&:hover
.item-info a
color: $color-primary
.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-title
color: $color-text-dark
display: block
font:
family: $font-body
size: 1.8em
padding: 10px 25px 10px
ul.meta
+list-meta
font-size: .9em
padding: 0px 25px 5px
.item-content
+node-details-description
font-size: 1.3em
padding: 15px 25px 25px
+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
.button-create,
.button-edit
+button($color-success, 3px, true)
.item-picture+.button-back+.button-edit
right: 20px
top: 20px
.comments-container
padding:
left: 20px
right: 20px
max-width: 680px
margin: 0 auto
+media-lg
padding:
left: 0
right: 0
.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
#blog_post-edit-form
padding: 0
@ -346,294 +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-create
display: block
width: 100%
+button($color-success, 6px)
margin: 0
.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_container
&.cloud-blog
#blog_index-container,
#blog_post-create-container,
#blog_post-edit-container
width: 100%
padding: 25px 30px 20px 30px
#blog_index-container+#blog_index-sidebar
display: none
#blog_index-container,
&.cloud-blog #blog_index-container
+media-sm
width: 100%
+media-xs
width: 100%
padding: 0 0 50px 0
margin: 0 auto
.blog_index-item
+media-xs
width: 100%
padding: 10px 25px
&.list
margin: 0 auto
padding: 15px 0
margin: 0 auto
border-bottom: thin solid $color-background
&:last-child
border-bottom: none
+media-xs
width: 100%
padding: 15px 10px
margin: 0
a.item-title
padding:
top: 0
bottom: 5px
font:
size: 1.6em
weight: 400
family: $font-body
.item-info
color: $color-text-dark-secondary
font-size: .9em
padding:
left: 25px
right: 25px
.item-header
width: 50px
height: 50px
position: absolute
top: 20px
border-radius: 3px
background-color: $color-background
overflow: hidden
img
+position-center-translate
width: 100%
i
+position-center-translate
font-size: 1.2em
color: $color-text-dark-hint
&.nothumb
border-radius: 50%
a.item-title, .item-info
padding-left: 70px
#blog_index-container
.blog_index-item
position: relative
+media-xs
padding: 25px 0 20px 0
&.list
padding: 15px 10px
margin: 0
+media-xs
width: 100%
padding: 15px 10px
margin: 0
.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
.blog-action
display: flex
padding: 10px
position: absolute
right: 0
top: 0
z-index: 1
// 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

@ -0,0 +1,72 @@
.alert
margin-bottom: 0
text-align: center
padding: 10px 20px
z-index: 16
// overriden by alert types
color: $color-text-dark
background-color: $color-background
&.alert-danger,
&.alert-error
background-color: lighten($color-danger, 35%)
color: $color-danger
.alert-icon, .close
color: $color-danger
&.alert-warning
background-color: lighten($color-warning, 20%)
color: darken($color-warning, 20%)
.alert-icon, .close
color: darken($color-warning, 20%)
&.alert-success
background-color: lighten($color-success, 45%)
color: $color-success
.alert-icon, .close
color: $color-success
&.alert-info
background-color: lighten($color-info, 30%)
color: darken($color-info, 10%)
.alert-icon, .close
color: darken($color-info, 10%)
button.close
position: absolute
right: 10px
i
font-size: .8em
i.alert-icon
&:before
font-family: "pillar-font"
padding-right: 10px
&.success:before
content: '\e801'
&.info:before
content: "\e80c"
&.warning:before
content: "\e80b"
&.danger:before,
&.error:before
content: "\e83d"
/* When there's an alert, disable the fixed top */
.alert+.navbar-fixed-top
position: relative
margin-bottom: 0
&+.container
padding-top: 0
.alert+.navbar
position: relative
.alert+.navbar+.page-content
padding-top: 0

View File

@ -0,0 +1,29 @@
body
height: 100%
+media-sm
width: 100%
max-width: 100%
min-width: auto
+media-xs
width: 100%
max-width: 100%
min-width: auto
.container
+media-xs
max-width: 100%
min-width: auto
padding:
left: 0
right: 0
&.box
+container-box
.page-content
background-color: $white
.container-box
+container-box

View File

@ -0,0 +1,14 @@
.btn-outline
background-color: transparent
border-width: 1px
transition: background-color .1s
&:focus, &:active
box-shadow: none
.btn-empty
background-color: transparent
border-color: transparent
&:focus, &:active
box-shadow: none

View File

@ -0,0 +1,142 @@
.card-deck
// Custom, as of bootstrap 4.1.3 there is no way to do this.
&.card-deck-responsive
@extend .row
.card
@extend .col-md-4
+media-sm
flex: 1 0 50%
max-width: 50%
+media-md
flex: 1 0 33%
max-width: 33%
+media-lg
flex: 1 0 33%
max-width: 33%
+media-xl
flex: 1 0 25%
max-width: 25%
+media-xxl
flex: 1 0 20%
max-width: 20%
&.card-3-columns .card
+media-xxl
flex: 1 0 33%
max-width: 33%
&.card-deck-vertical
@extend .flex-column
flex-wrap: initial
.card
@extend .w-100
@extend .flex-row
@extend .p-0
flex: initial
flex-wrap: wrap
max-width: 100%
.card-img-top
@extend .rounded-0
.embed-responsive
@extend .mr-2
max-width: 120px
&.asset
&.free
&:after
+ribbon
content: 'FREE'
font-size: .6rem
left: -40px
padding: 1px 45px
right: initial
transform: rotate(-45deg)
.card-body
@extend .overflow-hidden
flex-basis: 0
.card-padless
.card
@extend .border-0
.card-body
@extend .px-0
.card-image-fade
&:hover
.card-img-top
opacity: .9
.card.asset
color: $color-text
&:hover
text-decoration: none
&.free
overflow: hidden
&:after
+ribbon
content: 'FREE'
padding: 2px 50px
.card-body
position: relative // for placing the progress
.card-text
font-size: $font-size-xs
.card-img-top
background-color: $color-background
background-size: cover
background-position: center
$card-progress-height: 5px
.progress
height: $card-progress-height
position: absolute
top: -$card-progress-height
width: 100%
.card-img-top
&.card-icon
display: flex
align-items: center
justify-content: center
font-size: 2em
i
opacity: .2
/* Tiny label for cards. e.g. 'WATCHED' on videos. */
.card-label
background-color: rgba($black, .5)
border-radius: 3px
color: $white
display: block
font-size: $font-size-xxs
left: 5px
top: -27px // enough to be above the progress-bar
position: absolute
padding: 1px 5px
z-index: 1
.card
&.active
.card-title
color: $primary

View File

@ -0,0 +1,8 @@
.checkbox label label
padding-left: 0
.checkbox label input[type=checkbox] + label
transition: color 100ms ease-in-out
.checkbox label input[type=checkbox]:checked + label
color: $color-success !important

View File

@ -0,0 +1,44 @@
// Global, we want all menus to look like this.
ul.dropdown-menu
box-shadow: $dropdown-box-shadow
top: 95% // So there is less gap between the dropdown and the item.
> li
&:first-child > a
padding-top: ($dropdown-item-padding-y * 1.5)
&:last-child > a
padding-bottom: ($dropdown-item-padding-y * 1.5)
> a
padding-top: $dropdown-item-padding-y
padding-bottom: $dropdown-item-padding-y
.dropdown-divider
margin: 0
.dropdown-item:last-child
border-bottom-left-radius: $border-radius
border-bottom-right-radius: $border-radius
// Open dropdown on mouse hover dropdowns in the navbar.
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

@ -0,0 +1,25 @@
/* Flyouts (only used on notifications for now) */
.flyout
background-color: $color-background
border-radius: 3px
border: thin solid darken($color-background, 3%)
box-shadow: 1px 2px 2px rgba(black, .2)
display: block
font-size: .9em
& .flyout-title
cursor: default
display: block
float: left
font-size: 1.1em
font-weight: 600
padding: 8px 10px 5px 10px
&.notifications
max-height: 1000%
overflow-x: hidden
position: absolute
right: 0
top: 40px
width: 420px
z-index: 9999

View File

@ -0,0 +1,117 @@
/* FOOTER */
.footer-wrapper
background-color: $color-background
position: relative
&:after
background-color: $color-background
bottom: 0
content: ''
position: fixed
left: 0
right: 0
top: 0
pointer-events: none
z-index: -1
/* Footer Navigation */
footer
font-size: .75em
padding: 0 0 10px 0
a
color: $color-text-dark-primary
&:hover
color: $color-primary
ul.links
float: left
padding: 0
margin: 0
list-style-type: none
li
padding: 0 15px 0 0
margin: 0
float: left
#hop
display: flex
align-items: center
justify-content: center
visibility: hidden
position: fixed
right: 25px
bottom: 25px
z-index: 999
cursor: pointer
opacity: 0
background: $color-background-light
width: 32px
height: 32px
border-radius: 50%
color: $color-text-dark-secondary
font-size: 2em
box-shadow: 0 0 15px rgba(black, .2)
transform: scale(0.5)
transition: all 150ms ease-in-out
&:hover
transform: scale(1.2)
background-color: $color-background-nav
&.active
visibility: visible
opacity: 1
transform: scale(1)
.footer-navigation
font-size: .85em
margin-bottom: 5px
color: lighten($color-text, 30%)
border-top: thick solid lighten($color-text, 60%)
padding:
top: 15px
bottom: 15px
a
color: lighten($color-text, 35%)
&:hover
color: $color-primary
.footer-links
i
font-size: 80%
position: absolute
left: -14px
top: 20%
.special
padding:
top: 10px
bottom: 15px
font-size: .9em
border-left: thin solid darken($color-background, 20%)
img
max-width: 100%
opacity: .6
ul.footer-social
width: 100%
text-align:center
margin: 0 auto
display: flex
align-items: center
justify-content: space-around
li
display: inline-block
padding: 30px 0
i
font-size: 3em

View File

@ -0,0 +1,132 @@
/* File Upload forms */
.fieldlist
list-style: none
padding: 0
margin: 10px 0 0 0
li.fieldlist-item
background-color: $color-background-light
border: thin solid $color-background
border-left: 3px solid $color-primary
border-top-right-radius: 3px
border-bottom-right-radius: 3px
margin-bottom: 10px
padding: 10px
+clearfix
.form-group
margin-bottom: 0 !important // override bs
width: 100%
input.form-control
background-color: white !important
padding: 0 10px !important
border: thin solid $color-background-dark !important
div[class$="slug"]
width: 50%
float: left
display: flex
align-items: center
label
margin-right: 10px
.fieldlist-action-button
+button($color-success, 3px)
margin: 0 0 0 10px
padding: 5px 10px
text-transform: initial
.form-upload-file
margin-bottom: 10px
display: flex
flex-direction: column
.form-upload-progress
margin-top: 10px
.form-upload-progress-bar
margin-top: 5px
background-color: $color-success
height: 5px
min-width: 0
border-radius: 3px
&.progress-uploading
background-color: hsl(hue($color-success), 80%, 65%) !important
&.progress-processing
+stripes($color-success, lighten($color-success, 15%), -45deg, 25px)
+stripes-animate
animation-duration: 1s
&.progress-error
background-color: $color-danger !important
.preview-thumbnail
width: 50px
height: 50px
min-width: 50px
min-height: 50px
margin-right: 10px
margin-top: 5px
border-radius: 3px
background-color: $color-background
.form-upload-file-meta-container
display: flex
.form-upload-file-meta
list-style: none
padding: 0
margin: 0
width: 100%
display: flex
flex-wrap: wrap
flex: 1
li
display: inline-block
padding: 5px 10px
&:first-child
padding-left: 0
&.dimensions, &.size
color: $color-text-dark-secondary
&.delete
margin-left: auto
&.name
+text-overflow-ellipsis
.file_delete
color: $color-danger
.form-upload-file-actions
list-style: none
padding: 0
margin: 0
display: flex
flex-wrap: wrap
li
display: inline-block
padding: 5px 10px
.file_delete
color: $color-danger
.form-group
&.error
.form-control, input
border-color: $color-danger !important
ul.error
padding: 5px 0 0 0
margin: 0
color: $color-danger
list-style-type: none

View File

@ -0,0 +1,38 @@
/* Inputs */
input, input.form-control,
textarea, textarea.form-control,
select, select.form-control
+input-generic
label, label.control-label
+label-generic
select, select.form-control
border-top-left-radius: 3px
border-top-right-radius: 3px
background-color: $color-background-light
option
background-color: white
input.fileupload
background-color: transparent
display: block
margin-top: 10px
textarea
resize: vertical
button, .btn
&.disabled
opacity: .5 !important
pointer-events: none !important
text-shadow: none !important
user-select: none !important
.input-group-flex
display: flex
.input-group-separator
margin: 10px 0
border-top: thin solid $color-background

View File

@ -0,0 +1,47 @@
// Mainly overrides bootstrap jumbotron settings
.jumbotron
@extend .d-flex
@extend .mb-0
@extend .rounded-0
background:
position: center
repeat: no-repeat
size: cover
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
*
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)
&:hover
text-decoration: none

View File

@ -0,0 +1,255 @@
/* Top level navigation bar. */
.navbar
box-shadow: 0 2px $color-background
.nav
border: none
color: $color-text-dark-secondary
padding: 0
z-index: $z-index-base + 5
nav
margin-left: auto
margin-right: 0
.navbar-nav
margin-right: 0
+media-xs
margin: 0
width: 100%
li
user-select: none
position: relative
img.gravatar
height: 28px
position: relative
width: 28px
.special
background-color: white
border-radius: 999em
box-shadow: 1px 1px 1px rgba(black, .2)
display: inline-block
font-size: 1.2em
height: 18px
left: 28px
position: absolute
top: 3px
width: 18px
z-index: 2
&.subscriber
background-color: $color-success
color: white
font-size: .6em
&.demo
background-color: $color-info
color: white
font-size: .6em
&.none
color: $color-danger
i
+position-center-translate
.dropdown
.navbar-item
&:hover
box-shadow: none // Remove the blue underline usually on navbar, from dropdown items.
ul.dropdown-menu
li
a
white-space: nowrap
.subitem // e.g. "Not Sintel? Log out"
font-size: .8em
text-transform: initial
i
width: 30px
&.subscription-status
a, a:hover
color: $white
&.none
background-color: $color-danger
&.subscriber
background-color: $color-success
&.demo
background-color: $color-info
span.info
display: block
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
/* Secondary navigation. */
$nav-secondary-bar-size: -2px
.nav-secondary
align-items: center
.nav-link
color: $color-text
cursor: pointer
margin-bottom: 2px
transition: color 150ms ease-in-out
span
position: relative
top: 2px
&: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
// Blue bar on the bottom.
&:after
background-color: $primary-accent
background-image: linear-gradient(to right, $primary-accent 70%, $primary)
height: 2px
width: 100%
bottom: -2px
span
+active-gradient
i
color: $primary-accent
.nav-link.active
font-weight: bold
&.nav-secondary-vertical
align-items: flex-start
flex-direction: column
box-shadow: none // Last item on the list already has a box-shadow.
> li
width: 100% // span across the whole width.
// Blue bar on the side.
.nav-link
&:hover,
&.active
color: $primary
@extend .bg-white
&:after
background-image: linear-gradient($primary-accent 70%, $primary)
height: 100%
left: initial
top: 0
width: 3px
// Big navigation dropdown.
.nav-main
min-width: initial
.nav-secondary
.nav-link
@extend .pr-5
box-shadow: none
&.nav-see-more
color: $primary
i, span
+active-gradient
.navbar-overlay
+media-lg
display: block
bottom: 0
display: none
left: 0
height: 100%
position: absolute
right: 0
top: 0
transition: background-color 350ms ease-in-out
width: 100%
z-index: 0
&.is-active
background-color: $color-background-nav
text-shadow: none
nav.navbar
.navbar-collapse
> ul > li > .navbar-item
padding: $navbar-nav-link-padding-x
height: $nav-link-height
.navbar-backdrop-container
width: 100%
height: 100%
position: absolute
top: 0
left: 0
right: 0
bottom: 0
img
display: none
position: fixed
width: 100%
align-self: flex-start
+media-md
display: block
+media-lg
display: block
.nav-tabs .dropdown-menu, .nav-pills .dropdown-menu
margin-top: 0
.navbar+.page-content
padding-top: $nav-link-height

View File

@ -0,0 +1,75 @@
#page-overlay
background-color: rgba(black, .8)
position: fixed
top: 0
bottom: 0
right: 0
left: 0
z-index: $z-index-base + 15
visibility: hidden
opacity: 0
transition: opacity 150ms ease-in-out
display: flex
align-items: center
justify-content: center
img
user-select: none
display: block
max-height: 96%
max-width: 96%
z-index: 0
box-shadow: 0 0 15px rgba(black, .2), 0 0 100px rgba(black, .5)
p.caption
position: absolute
bottom: 1%
&.active
visibility: visible
opacity: 1
.no-preview
user-select: none
z-index: 0
color: $color-text-light-secondary
.nav-prev, .nav-next
display: block
font:
family: 'pillar-font'
size: 2em
height: 80%
width: 50px
cursor: pointer
color: $color-text-light-secondary
z-index: 1
+position-center-translate
&:hover
color: $color-text-light
&:before, &:after
+position-center-translate
.nav-prev
left: 50px
&:before
content: '\e839'
.nav-next
left: initial
right: 0
&:before
content: '\e83a'
&.video
.video-embed
+position-center-translate
position: fixed
iframe
width: 853px
height: 480px

View File

@ -0,0 +1,26 @@
.popover
background-color: lighten($color-background-nav, 5%)
border-radius: 3px
box-shadow: 1px 1px 2px rgba(black, .2)
border: thin solid lighten($color-background-nav, 10%)
&.in
opacity: 1
.popover-title
background-color: lighten($color-background-nav, 10%)
border-bottom: thin solid lighten($color-background-nav, 3%)
color: $color-text-light-primary
.popover-content
color: $color-text-light
font-size: .9em
&.top .arrow:after
border-top-color: lighten($color-background-nav, 5%)
&.bottom .arrow:after
border-bottom-color: lighten($color-background-nav, 5%)
&.left .arrow:after
border-left-color: lighten($color-background-nav, 5%)
&.right .arrow:after
border-right-color: lighten($color-background-nav, 5%)

View File

@ -0,0 +1,87 @@
#search-overlay
position: absolute
top: 0
left: 0
right: 0
bottom: 0
width: 100%
height: 100%
pointer-events: none
visibility: hidden
opacity: 0
z-index: $z-index-base + 4
transition: opacity 150ms ease-in-out
&.active
opacity: 1
visibility: visible
background-color: rgba($color-background-nav, .7)
.search-input
+media-lg
max-width: 350px
+media-md
max-width: 350px
+media-sm
max-width: 120px
+media-xs
display: block
margin: 0 10px
position: absolute
z-index: $z-index-base
right: 5px
position: relative
float: left
padding: 0
margin: 0
.search-icon
position: absolute
top: 4px
left: 10px
cursor: pointer
&:after
@extend .tooltip-inner
content: 'Use advanced search...'
font-size: .85em
font-style: normal
left: -10px
opacity: 0
pointer-events: none
position: absolute
top: 30px
transition: top 150ms ease-in-out, opacity 150ms ease-in-out
width: 150px
&:hover
&:after
opacity: 1
top: 35px
#cloud-search, .tt-hint
+text-overflow-ellipsis
border: thin solid $color-background
border-radius: 3px
font:
size: 1em
weight: 400
margin: 0
min-height: 32px
outline: none
padding: 0 20px 0 40px
transition: border 100ms ease-in-out
&:focus
box-shadow: none
border: none
&::placeholder
color: rgba($color-text, .5)
transition: color 150ms ease-in-out
&:hover
&::placeholder
color: rgba($color-text, .6)

View File

@ -0,0 +1,6 @@
p.shortcode.nocap
padding: 0.6em 3em
font-size: .8em
color: $color-text-dark-primary
background-color: $color-background-dark
border-radius: 3px

View File

@ -0,0 +1,21 @@
#status-bar
opacity: 0
transition: all 250ms ease-in-out
i
margin-right: 5px
&.info
color: $color-info
&.error
color: $color-danger
&.warning
color: $color-warning
&.success
color: $color-success
&.default
color: $color-text-light
&.active
opacity: 1

View File

@ -0,0 +1,5 @@
.tooltip
transition: none
.tooltip-inner
white-space: nowrap

View File

@ -1,9 +1,12 @@
/* So it's possible to override the path before importing font-pillar.sass */
/* SCROLL TO READ ABOUT UPDATING THIS FILE FROM FONTELLO */
/* Makes it possible to override the path before importing font-pillar.sass */
$pillar-font-path: "../font" !default
/* Font properties. */
@font-face
font-family: 'pillar-font'
src: url('#{$pillar-font-path}/pillar-font.woff?55726379') format("woff"), url('#{$pillar-font-path}/pillar-font.woff2?55726379') format("woff2")
src: url('#{$pillar-font-path}/pillar-font.woff?54788822') format("woff"), url('#{$pillar-font-path}/pillar-font.woff2?54788822') format("woff2")
font-weight: normal
font-style: normal
@ -17,23 +20,99 @@ $pillar-font-path: "../font" !default
width: 1em
margin-right: .2em
text-align: center
/* opacity: .8;
/* For safety - reset parent styles, that can break glyph codes
font-variant: normal
text-transform: none
/* fix buttons height, for twitter bootstrap
line-height: 1em
/* Animation center compensation - margins should be symmetric
/* remove if not needed
margin-left: .2em
/* you can be more comfortable with increased icons size
/* font-size: 120%;
/* Font smoothing. That was taken from TWBS
-webkit-font-smoothing: antialiased
-moz-osx-font-smoothing: grayscale
/* Uncomment for 3D effect
/* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3);
/* Icon aliases. */
/* Empty icons, multiple names for the same/unasigned icon, etc. */
.pi, .pi-blank
&:after
content: ''
font-family: "pillar-font"
font-style: normal
font-weight: normal
speak: none
display: inline-block
text-decoration: inherit
width: 1em
text-align: center
font-variant: normal
text-transform: none
line-height: 1em
-webkit-font-smoothing: antialiased
-moz-osx-font-smoothing: grayscale
position: relative
&:before
position: relative
.pi-svnman:before
content: '\f1c0'
/* Assets */
.pi-group
@extend .pi-folder
.pi-video
@extend .pi-film-thick
.pi-file
@extend .pi-file-archive
.pi-asset
@extend .pi-file-archive
.pi-group_texture
@extend .pi-folder-texture
.pi-post
@extend .pi-newspaper
.pi-page
@extend .pi-document
/* License */
.pi-license-cc-zero:before
content: '\e85a'
.pi-license-cc-sa:before
content: '\e858'
top: 1px
.pi-license-cc-nd:before
content: '\e859'
.pi-license-cc-nc:before
content: '\e857'
.pi-license-cc-0
@extend .pi-license-cc-zero
position: relative
top: 1px
.pi-license-cc-by-sa
@extend .pi-license-cc-sa
.pi-license-cc-by-nd
@extend .pi-license-cc-nd
.pi-license-cc-by-nc
@extend .pi-license-cc-nc
.pi-license-cc-by-sa,
.pi-license-cc-by-nd,
.pi-license-cc-by-nc
@extend .pi
&:after
content: '\e807'
left: -27px
&:before
left: 27px
/*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
* Here begins the CSS code generated by fontello.com by using *
* the config.json file in /pillar/web/static/assets/font *
* Just convert the icon classes from pillar-font.css to Sass *
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
* When adding icons, only add/overwrite icon classes e.g. .pi-bla *
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*/
.pi-collection-plus:before
content: '\e800'
@ -430,6 +509,11 @@ $pillar-font-path: "../font" !default
/* ''
.pi-speed:before
content: '\e84f'
/* ''
.pi-attention:before
content: '\e850'
@ -580,11 +664,6 @@ $pillar-font-path: "../font" !default
/* ''
.pi-users:before
content: '\e86e'
/* ''
.pi-flamenco:before
content: '\e86f'
@ -605,6 +684,11 @@ $pillar-font-path: "../font" !default
/* ''
.pi-users:before
content: '\e873'
/* ''
.pi-pause:before
content: '\f00e'
@ -640,6 +724,16 @@ $pillar-font-path: "../font" !default
/* ''
.pi-social-instagram:before
content: '\f16d'
/* ''
.pi-database:before
content: '\f1c0'
/* ''
.pi-newspaper:before
content: '\f1ea'

View File

@ -1,15 +1,84 @@
@import _normalize
// 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"
/* Generic styles (comments, notifications, etc) come from base.css */
@import _notifications
@import _comments
@import _project
@import _project-sharing
@import _project-dashboard
@import _user
@import _search
@import _organizations
@import _search
/* services, about, etc */
@import _pages

View File

@ -1,9 +1,8 @@
/* jsTree overrides */
$tree-color-text: $color-text-dark-primary
$tree-color-highlight: hsl(hue($color-background-active), 40%, 50%)
$tree-color-highlight-background: hsl(hue($color-background-active), 40%, 50%)
$tree-color-highlight-background-text: white
$tree-color-highlight: $color-primary-accent
$tree-color-highlight-background: $white
$tree-color-highlight-background-text: $primary
.jstree-default
/* list item */
@ -34,11 +33,10 @@ $tree-color-highlight-background-text: white
&[data-node-type="page"],
&[data-node-type="blog"]
color: darken($tree-color-highlight, 5%)
font-weight: bold
.jstree-anchor
padding: 5px 8px 1px 8px
padding: 0 6px
&:after
top: 3px !important
@ -63,49 +61,48 @@ $tree-color-highlight-background-text: white
&.jstree-open
/* Text of children for an open tree (like a folder) */
.jstree-children > .jstree-node
padding-left: 15px !important
padding-left: 16px !important
.jstree-icon:empty
left: 20px !important
// Tweaks for specific icons
&.pi-file-archive
left: 22px !important
left: 25px !important
&.pi-folder
left: 21px !important
left: 20px !important
font-size: .9em !important
&.pi-film-thick
left: 22px !important
&.pi-splay
left: 20px !important
font-size: .85em !important
.jstree-anchor
box-shadow: inset 1px 0 0 0 rgba($tree-color-text, .2)
// box-shadow: inset 1px 0 0 0 $color-background
/* Closed Folder */
// &.jstree-closed
&.jstree-open .jstree-icon.jstree-ocl,
&.jstree-closed .jstree-icon.jstree-ocl
float: left
min-width: 30px
opacity: 0
position: absolute
z-index: 1
opacity: 0
min-width: 30px
float: left
/* The text of the last level item */
.jstree-anchor
+media-xs
width: 98%
padding: 0 !important
width: 98%
border: none
font-size: 13px
height: inherit
line-height: 26px
line-height: 24px
overflow: hidden
padding-left: 28px
padding-right: 10px
text-overflow: ellipsis
transition: none
transition: color 50ms ease-in-out, background-color 100ms ease-in-out
white-space: nowrap
width: 100%
@ -113,7 +110,7 @@ $tree-color-highlight-background-text: white
&:after
content: '\e83a' !important
font-family: 'pillar-font'
color: white
color: $tree-color-highlight-background-text
display: none
position: absolute
right: 7px
@ -121,34 +118,29 @@ $tree-color-highlight-background-text: white
// Icon, not selected
.jstree-icon
color: $color-text-dark-secondary
font-size: 95% !important
color: $tree-color-text
margin: 0 !important
/* Selected item */
&.jstree-clicked
background-color: $tree-color-highlight-background !important
color: white !important
color: $tree-color-highlight-background-text !important
font-weight: bold
&:after
display: block
color: white !important
color: $tree-color-highlight-background-text !important
.jstree-ocl,
.jstree-icon
color: white
color: $tree-color-highlight-background-text
/* hover an active item */
&.jstree-hovered
background-color: lighten($tree-color-highlight-background, 10%) !important
box-shadow: none
color: white !important
color: $tree-color-highlight-background-text !important
&.jstree-hovered .jstree-icon
color: white !important
.jstree-hovered
background-color: rgba($tree-color-highlight, .1) !important
color: $tree-color-highlight-background-text !important
.jstree-leaf .jstree-clicked
width: 100% !important
@ -184,8 +176,8 @@ $tree-color-highlight-background-text: white
position: absolute
&:empty
line-height: 26px
left: 5px
line-height: 24px
left: 3px
&.is_subscriber
.jstree-node
@ -196,63 +188,6 @@ $tree-color-highlight-background-text: white
&:after
display: none !important
&.blog
.jstree-anchor
padding: 6px 6px 6px 12px
&:hover
color: $tree-color-highlight
&.post
border-bottom: thin solid $color-background-dark
&.jstree-clicked
&.post
background-color: transparent !important
&:after
top: 8px
color: $tree-color-highlight !important
.tree-item-info
color: $color-text
.tree-item-title
color: $tree-color-highlight
.tree-item
line-height: initial
padding-right: 10px
&-title
font-size: 1.2em
overflow: initial
text-overflow: initial
white-space: normal
&-info
color: $color-text-dark-secondary
display: block
font-size: .8em
padding: 5px
&-thumbnail
align-items: center
background-color: $color-background
border-radius: 3px
color: $color-text-dark-secondary
display: flex
float: left
height: 70px
justify-content: center
margin: 0 10px 0 -5px
width: 70px
img
height: 70px
width: 70px
.jstree-loading
padding: 5px
color: $color-text-light-secondary
@ -269,7 +204,7 @@ $tree-color-highlight-background-text: white
.jstree-default .jstree-node.jstree-closed .jstree-icon.jstree-ocl + .jstree-anchor,
.jstree-default .jstree-node.jstree-open .jstree-icon.jstree-ocl + .jstree-anchor
padding-left: 28px !important
padding-left: 24px !important
/* hovered text */
.jstree-default .jstree-hovered,
@ -279,12 +214,10 @@ $tree-color-highlight-background-text: white
a.jstree-anchor.jstree-clicked+ul li a.jstree-anchor.jstree-clicked
background-color: rgba($tree-color-highlight-background, .8) !important
color: white !important
color: $tree-color-highlight-background-text !important
a.jstree-anchor.jstree-clicked+ul li a.jstree-anchor.jstree-clicked+ul li a.jstree-anchor.jstree-clicked
background-color: rgba($tree-color-highlight-background, .8) !important
color: white !important
color: $tree-color-highlight-background-text !important
i.jstree-icon.jstree-ocl
color: rgba($tree-color-text, .5) !important

View File

@ -1,5 +1,7 @@
$videoplayer-controls-color: white
$videoplayer-background-color: $color-background-nav
$videoplayer-background-color: darken($primary, 10%)
$videoplayer-progress-bar-height: .5em
.video-js
.vjs-big-play-button:before, .vjs-control:before, .vjs-modal-dialog
@ -30,7 +32,6 @@ $videoplayer-background-color: $color-background-nav
font-weight: normal
font-style: normal
.vjs-icon-play
font-family: VideoJS
font-weight: normal
@ -285,7 +286,6 @@ $videoplayer-background-color: $color-background-nav
line-height: 1
font-weight: normal
font-style: normal
font-family: Arial, Helvetica, sans-serif
-webkit-user-select: none
-moz-user-select: none
-ms-user-select: none
@ -453,20 +453,22 @@ body.vjs-full-window
list-style: none
margin: 0
padding: 0.2em 0
line-height: 1.4em
font-size: 1.2em
line-height: 1.8em
font-size: 1.1em
text-align: center
text-transform: lowercase
&:focus, &:hover
outline: 0
background-color: #73859f
background-color: rgba(115, 133, 159, 0.5)
background-color: darken($primary, 20%)
&.vjs-selected
background-color: $videoplayer-controls-color
color: $videoplayer-background-color
&:focus, &:hover
background-color: $videoplayer-controls-color
color: $videoplayer-background-color
&.vjs-menu-title
text-align: center
text-transform: uppercase
@ -486,12 +488,13 @@ body.vjs-full-window
height: 0em
margin-bottom: 1.5em
border-top-color: $videoplayer-background-color
.vjs-menu-content
background-color: $videoplayer-background-color
position: absolute
width: 100%
bottom: 1.5em
max-height: 15em
max-height: 25em
.vjs-workinghover .vjs-menu-button-popup:hover .vjs-menu, .vjs-menu-button-popup .vjs-menu.vjs-lock-showing
display: block
@ -655,12 +658,12 @@ body.vjs-full-window
-moz-transition: all 0.2s
-o-transition: all 0.2s
transition: all 0.2s
height: 0.3em
height: $videoplayer-progress-bar-height
.vjs-play-progress
position: absolute
display: block
height: 0.3em
height: $videoplayer-progress-bar-height
margin: 0
padding: 0
width: 0
@ -670,7 +673,7 @@ body.vjs-full-window
.vjs-load-progress
position: absolute
display: block
height: 0.3em
height: $videoplayer-progress-bar-height
margin: 0
padding: 0
width: 0
@ -680,7 +683,7 @@ body.vjs-full-window
div
position: absolute
display: block
height: 0.3em
height: $videoplayer-progress-bar-height
margin: 0
padding: 0
width: 0
@ -692,10 +695,11 @@ body.vjs-full-window
.vjs-play-progress
background-color: $videoplayer-controls-color
border-radius: 999em
&:before
position: absolute
top: -0.333333333333333em
top: -($videoplayer-progress-bar-height / 2) // halfway the height of the progress bar
right: -0.5em
&:after
@ -712,8 +716,8 @@ body.vjs-full-window
z-index: 1
.vjs-time-tooltip
background-color: $videoplayer-background-color
color: $videoplayer-controls-color
background-color: $videoplayer-background-color
z-index: 1
&:after
@ -735,9 +739,9 @@ body.vjs-full-window
.vjs-time-tooltip
background-color: $videoplayer-controls-color
border-radius: 3px
border-radius: $border-radius
color: $videoplayer-background-color
font-family: $font-body
font-family: $font-family-base
font-size: 1.2em
font-weight: bold
padding: 5px 8px
@ -851,9 +855,9 @@ body.vjs-full-window
font-size: 0.9em
.vjs-slider-horizontal .vjs-volume-level
height: 0.3em
height: $videoplayer-progress-bar-height
&:before
top: -0.3em
top: -$videoplayer-progress-bar-height
right: -0.5em
.vjs-menu-button-popup
@ -1022,14 +1026,15 @@ video::-webkit-media-text-track-display
.vjs-playback-rate
.vjs-playback-rate-value
font-size: 1.5em
font-size: 1.25em
line-height: 2
position: absolute
top: 0
top: 3px
left: 0
width: 100%
height: 100%
text-align: center
.vjs-menu
width: 4em
left: 0em
@ -1041,7 +1046,6 @@ video::-webkit-media-text-track-display
&:before
color: $videoplayer-controls-color
content: 'X'
font-family: Arial, Helvetica, sans-serif
font-size: 4em
left: 0
line-height: 1

View File

@ -1,14 +0,0 @@
@import _normalize
@import _config
@import _utils
@import _comments
@import _project
@import _project-sharing
@import _project-dashboard
@import _error
@import _search
@import plugins/_jstree
@import plugins/_js_select2

View File

@ -7,8 +7,7 @@ $color-theatre-background-dark: darken($color-theatre-background, 5%)
$theatre-width: 350px
body.theatre,
body.theatre .container-page
body.theatre
background-color: $color-theatre-background
nav.navbar
+media-lg
@ -26,6 +25,7 @@ body.theatre .container-page
display: flex
align-items: center
justify-content: center
.page-body
height: 100%
width: 100%

View File

@ -6,20 +6,22 @@
| {% if node_type_name == 'group' %}
| {% set node_type_name = 'folder' %}
| {% endif %}
li(class="button-{{ node_type['name'] }}")
a.item_add_node(
href="#",
title="{{ node_type['description'] }}",
data-node-type-name="{{ node_type['name'] }}",
data-toggle="tooltip",
data-placement="left")
i.pi(class="icon-{{ node_type['name'] }}")
li
a.dropdown-item(
class="item_add_node",
href="#",
title="{{ node_type['description'] }}",
data-node-type-name="{{ node_type['name'] }}",
data-toggle="tooltip",
data-placement="left")
i.pi(class="pi-{{ node_type['name'] }}")
| {% if node_type_name == 'group_texture' %}
| Texture Folder
| {% elif node_type_name == 'group_hdri' %}
| HDRi Folder
| {% else %}
| {{ node_type_name }}
span.text-capitalize
|{{ node_type_name }}
| {% endif %}
| {% endif %}
| {% endfor %}

View File

@ -0,0 +1,52 @@
| {% macro asset_list_item(asset, current_user) %}
| {% set node_type = asset.properties.content_type if asset.properties.content_type else asset.node_type %}
a.card.asset.card-image-fade.pr-0.mx-0.mb-2(
class="js-item-open {% if asset.permissions.world %}free{% endif %}",
data-node_id="{{ asset._id }}",
title="{{ asset.name }}",
href='{{ url_for_node(node=asset) }}')
.embed-responsive.embed-responsive-16by9
| {% if asset.picture %}
.card-img-top.embed-responsive-item(style="background-image: url({{ asset.picture.thumbnail('m', api=api) }})")
| {% else %}
.card-img-top.card-icon.embed-responsive-item
i(class="pi-{{ node_type }}")
| {% endif %}
.card-body.py-2.d-flex.flex-column.text-truncate
.card-title.mb-1.font-weight-bold.text-truncate
| {{ asset.name | hide_none }}
ul.card-text.list-unstyled.d-flex.text-black-50.mt-auto.mb-0.text-truncate
li.pr-2.font-weight-bold {{ node_type | undertitle | hide_none }}
li.pr-2.text-truncate {{ asset.project.name | hide_none }}
li.pr-2.text-truncate {{ asset.user.full_name | hide_none }}
li.text-truncate {{ asset._created | pretty_date | hide_none }}
| {% if asset.properties.content_type == 'video' %}
| {% set view_progress = current_user.nodes.view_progress %}
| {% if asset._id in view_progress %}
| {% set progress = current_user.nodes.view_progress[asset._id] %}
| {% set progress_in_percent = progress.progress_in_percent %}
| {% set progress_done = progress.done %}
| {% endif %}
| {% if progress %}
.progress.rounded-0
.progress-bar(
role="progressbar",
style="width: {{ progress_in_percent }}%;",
aria-valuenow="{{ progress_in_percent }}",
aria-valuemin="0",
aria-valuemax="100")
| {% if progress.done %}
.card-label WATCHED
| {% endif %}
| {% endif %} {# endif progress #}
| {% endif %} {# endif video #}
| {% endmacro %}

View File

@ -28,15 +28,15 @@
span Add files...
input(type='file', name='file', multiple='')
button.btn.btn-primary.start(type='submit')
button.btn.btn-outline-primary.start(type='submit')
i.pi-upload
span Start upload
span Start Upload
button.btn.btn-warning.cancel(type='reset')
button.btn.btn-outline-warning.cancel(type='reset')
i.pi-cancel
span Cancel upload
span Cancel Upload
button.btn.btn-danger.delete(type='button')
button.btn.btn-outline-danger.delete(type='button')
i.pi-trash
span Delete

View File

@ -23,7 +23,7 @@ script#template-upload(type="text/x-tmpl").
</button>
{% } %}
{% if (!i) { %}
<button class="btn btn-warning cancel">
<button class="btn btn-outline-secondary cancel">
<i class="ion-close-round"></i>
<span>Cancel</span>
</button>
@ -61,7 +61,7 @@ script#template-download(type="text/x-tmpl").
</td>
<td>
{% if (file.deleteUrl) { %}
<button class="btn btn-danger delete" data-type="{%=file.deleteType%}" data-url="{%=file.deleteUrl%}"{% if (file.deleteWithCredentials) { %} data-xhr-fields='{"withCredentials":true}'{% } %}>
<button class="btn btn-outline-danger delete" data-type="{%=file.deleteType%}" data-url="{%=file.deleteUrl%}"{% if (file.deleteWithCredentials) { %} data-xhr-fields='{"withCredentials":true}'{% } %}>
<i class="ion-trash-b"></i>
<span>Delete</span>
</button>
@ -71,7 +71,7 @@ script#template-download(type="text/x-tmpl").
Create
</div>
{% } else { %}
<button class="btn btn-warning cancel">
<button class="btn btn-outline-secondary cancel">
<i class="ion-close-round"></i>
<span>Cancel</span>
</button>

View File

@ -1,26 +0,0 @@
| {% macro navigation_tabs(title) %}
nav#nav-tabs
ul#nav-tabs__list
li.nav-tabs__list-tab(
class="{% if title == 'homepage' %}active{% endif %}")
a(href="{{ url_for('main.homepage') }}") Activity
li.nav-tabs__list-tab(
class="{% if title == 'home' %}active{% endif %}")
a(href="{{ url_for('projects.home_project') }}") Home
li.nav-tabs__list-tab(
class="{% if title == 'dashboard' %}active{% endif %}")
a(href="{{ url_for('projects.index') }}") My Projects
| {% if current_user.has_organizations() %}
li.nav-tabs__list-tab(
class="{% if title == 'organizations' %}active{% endif %}")
a(
href="{{ url_for('pillar.web.organizations.index') }}",
title="My Organizations")
| My Organizations
| {% endif %}
| {% endmacro %}

View File

@ -9,5 +9,5 @@
.modal-body
| ...
.modal-footer
button.btn.btn-default(type='button', data-dismiss='modal') Close
button.btn.btn-outline-secondary(type='button', data-dismiss='modal') Close
button.btn.btn-primary(type='button') Save changes

View File

@ -29,32 +29,20 @@ html(lang="en")
meta(name="twitter:image", content="")
| {% endblock %}
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery-3.1.0.min.js')}}")
script(src="{{ url_for('static_pillar', filename='assets/js/tutti.min.js') }}")
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.typeahead-0.11.1.min.js')}}")
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/js.cookie-2.0.3.min.js')}}")
| {% if current_user.is_authenticated %}
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/clipboard.min.js')}}")
| {% endif %}
script.
!function(e){"use strict";e.loadCSS=function(t,n,o){var r,i=e.document,l=i.createElement("link");if(n)r=n;else{var d=(i.body||i.getElementsByTagName("head")[0]).childNodes;r=d[d.length-1]}var a=i.styleSheets;l.rel="stylesheet",l.href=t,l.media="only x",r.parentNode.insertBefore(l,n?r:r.nextSibling);var f=function(e){for(var t=l.href,n=a.length;n--;)if(a[n].href===t)return e();setTimeout(function(){f(e)})};return l.onloadcssdefined=f,f(function(){l.media=o||"all"}),l},"undefined"!=typeof module&&(module.exports=e.loadCSS)}(this);
loadCSS( "//fonts.googleapis.com/css?family=Roboto:300,400" );
script(src="{{ url_for('static_pillar', filename='assets/js/markdown.min.js') }}")
script(src="{{ url_for('static_pillar', filename='assets/js/tutti.min.js') }}")
link(href="{{ url_for('static', filename='assets/img/favicon.png') }}", rel="shortcut icon")
link(href="{{ url_for('static', filename='assets/img/apple-touch-icon-precomposed.png') }}", rel="icon apple-touch-icon-precomposed", sizes="192x192")
link(href="{{ url_for('static_pillar', filename='assets/css/vendor/bootstrap.min.css') }}", rel="stylesheet")
| {% block head %}{% endblock %}
| {% 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 %}
@ -66,10 +54,9 @@ html(lang="en")
| {% if not title %}{% set title="default" %}{% endif %}
body(class="{{ title }}")
.container-page
.page-content
.page-body
| {% block body %}{% endblock %}
.page-content
.page-body
| {% block body %}{% endblock %}
| {% block footer_container %}
#footer-container
@ -84,8 +71,6 @@ html(lang="en")
| {% endblock footer %}
| {% endblock footer_container%}
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.bootstrap-3.3.7.min.js') }}")
| {% if current_user.is_authenticated %}
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.typewatch-3.0.0.min.js') }}")
script.

View File

@ -1,10 +1,11 @@
| {% if current_user.is_authenticated %}
li.nav-notifications
a.navbar-item#notifications-toggle(
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

@ -1,7 +1,7 @@
| {% block menu_body %}
| {% if current_user.is_authenticated %}
li(class="dropdown")
li.dropdown
| {% block menu_avatar %}
a.navbar-item.dropdown-toggle(href="#", data-toggle="dropdown", title="{{ current_user.email }}")
img.gravatar(
@ -9,58 +9,36 @@ li(class="dropdown")
alt="Avatar")
| {% endblock menu_avatar %}
ul.dropdown-menu
ul.dropdown-menu.dropdown-menu-right
| {% if not current_user.has_role('protected') %}
| {% block menu_list %}
li
a.navbar-item(
href="{{ url_for('projects.home_project') }}"
title="Home")
i.pi-home
| Home
li
a.navbar-item(
href="{{ url_for('projects.index') }}"
title="My Projects")
i.pi-star
| My Projects
| {% if current_user.has_organizations() %}
li
a.navbar-item(
href="{{ url_for('pillar.web.organizations.index') }}"
title="My Organizations")
i.pi-users
| My Organizations
| {% endif %}
li
a.navbar-item(
href="{{ url_for('settings.profile') }}"
title="Settings")
i.pi-cog
| Settings
a.navbar-item.px-2(
href="{{ url_for('settings.profile') }}"
title="Settings")
| #[i.pi-cog] Settings
| {% endblock menu_list %}
li.divider(role="separator")
li.dropdown-divider(role="separator")
| {% endif %}
li
a.navbar-item(
href="{{ url_for('users.logout') }}")
href="{{ url_for('users.logout') }}")
i.pi-log-out(title="Log Out")
| Log out
a.navbar-item.subitem(
href="{{ url_for('users.switch') }}")
a.navbar-item.subitem.pt-0(
href="{{ url_for('users.switch') }}")
i.pi-blank
| Not {{ current_user.full_name }}?
| {% else %}
li.nav-item-sign-in
a.navbar-item(href="{{ url_for('users.login') }}")
| Log in
li.pr-1
a.btn.btn-sm.btn-outline-primary.px-3(
href="{{ url_for('users.login') }}")
| Log In
| {% endif %}
| {% endblock menu_body %}

View File

@ -0,0 +1,73 @@
// {#
// Header of landing pages. title or text can be skipped:
// +jumbotron("{{ page_title }}", null, "{{ page_header_image }}")
// Any extra attributes added (in a separate group) will be passed as is:
// +jumbotron("{{ page_title }}", null, "{{ page_header_image }}")(data-node-id='{{ node._id }}')
// #}
mixin jumbotron(title, text, image, url)
if url
a.jumbotron.text-white(
style='background-image: url(' + image + ');',
href=url)&attributes(attributes)
.container
.row
.col-md-9
if title
.display-4.text-uppercase.font-weight-bold
=title
if text
.lead
=text
else
.jumbotron.text-white(style='background-image: url(' + image + ');')&attributes(attributes)
.container
.row
.col-md-9
if title
.display-4.text-uppercase.font-weight-bold
=title
if text
.lead
=text
// {# Secondary navigation.
// e.g. Workshops, Courses. #}
mixin nav-secondary(title)
ul.nav.nav-secondary&attributes(attributes)
if title
li.nav-item
span.nav-title.nav-link.font-weight-bold.pointer-events-none= title
if block
block
else
p No items defined.
mixin nav-secondary-link()
li.nav-item
a.nav-link&attributes(attributes)
block
mixin card-deck(max_columns)
.card-deck.card-padless.card-deck-responsive(class="card-" + max_columns + "-columns")&attributes(attributes)
if block
block
else
.p-3 No items.
// {#
// Passes all attributes to the card.
// You can do fun stuff in a loop even like:
// +card(data-url="{{ url_for('projects.view', project_url=project.url) }}", tabindex='{{ loop.index }}')
// #}
mixin card()
.card&attributes(attributes)
if block
block
else
p No card content defined.
mixin list-asset(name, url, image, type, date)
if block
block

View File

@ -5,21 +5,21 @@ section.node-preview-forbidden
div
p Available to Blender Cloud subscribers
hr
hr.bg-white
| {% if current_user.has_cap('can-renew-subscription') %}
p
small You have a subscription, it just needs to be renewed.
a.btn(href="/renew")
a.btn.btn-light(href="/renew")
| #[i.pi-heart] Renew Subscription
| {% else %}
p
small Support Blender and get awesome stuff!
a.btn(href="{{ url_for('cloud.join') }}")
a.btn.btn-light(href="{{ url_for('cloud.join') }}")
| #[i.pi-heart] Get a Subscription
| {% endif %}
| {% if current_user.is_anonymous %}
p(style="margin-top: 15px")
small
a(href="{{ url_for('users.login') }}") Already a subscriber? Log in
a.text-white(href="{{ url_for('users.login') }}") Already a subscriber? Log in
| {% endif %}

View File

@ -33,8 +33,8 @@ script(type="text/javascript").
} else if (node_type === 'group_hdri') {
node_type_str = 'HDRi Folder';
}
$('a', '.button-edit').html('<i class="pi-edit button-edit-icon"></i> Edit ' + node_type_str);
$('a', '.button-delete').html('<i class="pi-trash button-delete-icon"></i>Delete ' + node_type_str);
$('a', '.button-delete').html('<i class="pr-2 pi-trash button-delete-icon"></i>Delete ' + node_type_str);
{% if parent %}
ProjectUtils.setProjectAttributes({parentNodeId: '{{parent._id}}'});
@ -61,7 +61,7 @@ script(type="text/javascript").
}
{% if node.has_method('PUT') %}
$('.project-mode-view').show();
$('.project-mode-view').displayAs('inline-block');
{% else %}
$('.project-mode-view').hide();
{% endif %}
@ -114,7 +114,6 @@ script(type="text/javascript").
}
}
$(page_overlay).find('.nav-prev').click(function(e){
e.stopPropagation();
e.preventDefault();
@ -133,10 +132,6 @@ script(type="text/javascript").
$(this).removeClass('active').hide().html();
});
if (typeof $().popover != 'undefined'){
$('#asset-license').popover();
}
{% endif %}
var $content_type = $(".js-type");

View File

@ -23,7 +23,7 @@ section.node-preview.video
| {% block node_download %}
| {% if node.file_variations %}
button.btn.btn-default.dropdown-toggle(
button.btn.btn-outline-primary.dropdown-toggle.px-3(
type="button",
data-toggle="dropdown",
aria-haspopup="true",
@ -32,7 +32,7 @@ button.btn.btn-default.dropdown-toggle(
| Download
i.pi-angle-down.icon-dropdown-menu
ul.dropdown-menu
ul.dropdown-menu.dropdown-menu-right
| {% for variation in node.file_variations %}
li
a(href="{{ variation.link }}",
@ -52,25 +52,49 @@ script(type="text/javascript").
{% if node.video_sources %}
var videoPlayer = document.getElementById('videoplayer');
var options = {
controlBar: {
volumePanel: { inline: false }
}
},
playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 4]
};
videojs.registerPlugin('analytics', function() {
videojs(videoPlayer, options).ready(function() {
this.ga({
'eventLabel' : '{{ node._id }} - {{ node.name }}',
'eventCategory' : '{{ node.project }}',
'eventsToTrack' : ['start', 'error', 'percentsPlayed']
});
this.hotkeys({
enableVolumeScroll: false,
customKeys: {
KeyL: {
key: function(event) {
return (event.which === 76);
},
handler: function(player, options, event) {
videoPlayerToggleLoop(videoPlayer, videoPlayerLoopButton);
}
}
}
});
this.rememberVolumePlugin();
{% if current_user.is_authenticated %}
let fetch_progress_url = '{{ url_for("users_api.get_video_progress", video_id=node._id) }}';
let report_url = '{{ url_for("users_api.set_video_progress", video_id=node._id) }}';
this.progressPlugin({
'report_url': report_url,
'fetch_progress_url': fetch_progress_url,
});
{% endif %}
});
videojs(videoPlayer, options).ready(function() {
this.hotkeys();
});
// Generic utility to add-buttons to the player.
function addVideoPlayerButton(data) {
var controlBar,
@ -89,6 +113,7 @@ script(type="text/javascript").
return newButton;
}
// Video loop stuff. TODO: Move it to video_plugins.js
var videoPlayerLoopButton = addVideoPlayerButton({
player: videoPlayer,
class: 'vjs-loop-button',
@ -96,15 +121,18 @@ script(type="text/javascript").
title: 'Loop'
});
videoPlayerLoopButton.onclick = function() {
function videoPlayerToggleLoop(videoPlayer, videoPlayerLoopButton) {
if (videoPlayer.loop){
videoPlayer.loop = false;
$(this).removeClass('vjs-control-active');
$(videoPlayerLoopButton).removeClass('vjs-control-active');
} else {
videoPlayer.loop = true;
$(this).addClass('vjs-control-active');
$(videoPlayerLoopButton).addClass('vjs-control-active');
}
}
videoPlayerLoopButton.onclick = function() {
videoPlayerToggleLoop(videoPlayer, videoPlayerLoopButton);
};
{% endif %} // if node.video_sources

View File

@ -1,142 +0,0 @@
//- ******************************************************* -//
| {% import 'projects/_macros.html' as projectmacros %}
| {% macro render_blog_post(node, project=None, pages=None) %}
| {% if node.picture %}
a.blog_index-header(href="{{ node.url }}")
img(src="{{ node.picture.thumbnail('h', api=api) }}")
| {% endif %}
| {% if project and project._id != config.MAIN_PROJECT_ID %}
| {{ projectmacros.render_secondary_navigation(project, pages=pages) }}
| {% endif %}
.blog_index-item
a.item-title(
href="{{ node.url }}")
| {{ node.name }}
ul.meta
| {% if node.project.name %}
li {{ node.project.name }}
| {% endif %}
| {% if node.user.full_name%}
li.who
| by {{ node.user.full_name }}
| {% endif %}
li.when
a(href="{{ node.url }}",
title="Updated {{ node._updated | pretty_date }}")
| {{ node._created | pretty_date }}
li
a(href="{{ node.url }}#comments")
| comment
.item-content
| {{ node.properties | markdowned('content') }}
| {% endmacro %}
//- ******************************************************* -//
| {% macro render_blog_list_item(node) %}
.blog_index-item.list
| {% if node.picture %}
a.item-header(href="{{ node.url }}")
img.image(src="{{ node.picture.thumbnail('s', api=api) }}")
| {% else %}
a.item-header.nothumb(href="{{ node.url }}")
i.pi-document-text
| {% endif %}
a.item-title(
href="{{ node.url }}")
| {{node.name}}
.item-info.
#[span(title="{{node._created}}") {{node._created | pretty_date }}]
{% if node._created != node._updated %}
#[span(title="{{node._updated}}") (updated {{node._updated | pretty_date }})]
{% endif %}
{% if node.properties.category %} · {{node.properties.category}}{% endif %}
· {{node.user.full_name}}
{% if node.properties.status != 'published' %} · {{ node.properties.status}} {% endif %}
| {% endmacro %}
//- ******************************************************* -//
| {% macro render_blog_index(project, posts, can_create_blog_posts, api, more_posts_available, posts_meta, pages=None) %}
| {% if can_create_blog_posts %}
.blog-action
a.btn.btn-default.button-create(href="{{url_for('nodes.posts_create', project_id=project._id)}}")
i.pi-plus
| Create New Post
| {% endif %}
| {% if posts %}
| {{ render_blog_post(posts[0], project=project, pages=pages) }}
| {% for node in posts[1:] %}
| {% if loop.first %}
.blog-archive-navigation
span Blasts from the past
| {% endif %}
| {{ render_blog_list_item(node) }}
| {% endfor %}
| {% if more_posts_available %}
.blog-archive-navigation
a(href="{{ project.blog_archive_url }}")
| {{posts_meta.total - posts|length}} more blog posts over here
i.pi-angle-right
| {% endif %}
| {% else %}
.blog_index-item
.item-content No posts... yet!
| {% endif %} {# posts #}
| {% endmacro %}
//- Macro for rendering the navigation buttons for prev/next pages -//
| {% macro render_archive_pagination(project) %}
.blog-archive-navigation
| {% if project.blog_archive_prev %}
a.archive-nav-button(
href="{{ project.blog_archive_prev }}", rel="prev")
i.pi-angle-left
| Previous page
| {% else %}
span.archive-nav-button
i.pi-angle-left
| Previous page
| {% endif %}
a.archive-nav-button(
href="{{ url_for('main.project_blog', project_url=project.url) }}")
| Blog Index
| {% if project.blog_archive_next %}
a.archive-nav-button(
href="{{ project.blog_archive_next }}", rel="next")
| Next page
i.pi-angle-right
| {% else %}
span.archive-nav-button
| Next page
i.pi-angle-right
| {% endif %}
| {% endmacro %}
| {% macro render_archive(project, posts, posts_meta) %}
| {{ render_archive_pagination(project) }}
| {% for node in posts %}
| {{ render_blog_list_item(node) }}
| {% endfor %}
| {{ render_archive_pagination(project) }}
| {% endmacro %}

Some files were not shown because too many files have changed in this diff Show More