Merge branch 'master' into production

This commit is contained in:
2018-09-18 15:50:55 +02:00
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/css/*.css
pillar/web/static/assets/js/*.min.js pillar/web/static/assets/js/*.min.js
pillar/web/static/assets/js/vendor/video.min.js
pillar/web/static/storage/ pillar/web/static/storage/
pillar/web/static/uploads/ pillar/web/static/uploads/
pillar/web/templates/ pillar/web/templates/

View File

@@ -12,15 +12,16 @@ var pug = require('gulp-pug');
var rename = require('gulp-rename'); var rename = require('gulp-rename');
var sass = require('gulp-sass'); var sass = require('gulp-sass');
var sourcemaps = require('gulp-sourcemaps'); var sourcemaps = require('gulp-sourcemaps');
var uglify = require('gulp-uglify'); var uglify = require('gulp-uglify-es').default;
var enabled = { var enabled = {
uglify: argv.production, uglify: argv.production,
maps: argv.production, maps: !argv.production,
failCheck: !argv.production, failCheck: !argv.production,
prettyPug: !argv.production, prettyPug: !argv.production,
cachify: !argv.production, cachify: !argv.production,
cleanup: argv.production, cleanup: argv.production,
chmod: argv.production,
}; };
var destination = { var destination = {
@@ -29,6 +30,11 @@ var destination = {
js: 'pillar/web/static/assets/js', js: 'pillar/web/static/assets/js',
} }
var source = {
bootstrap: 'node_modules/bootstrap/',
jquery: 'node_modules/jquery/',
popper: 'node_modules/popper.js/'
}
/* CSS */ /* CSS */
gulp.task('styles', function() { gulp.task('styles', function() {
@@ -67,36 +73,52 @@ gulp.task('scripts', function() {
.pipe(gulpif(enabled.uglify, uglify())) .pipe(gulpif(enabled.uglify, uglify()))
.pipe(rename({suffix: '.min'})) .pipe(rename({suffix: '.min'}))
.pipe(gulpif(enabled.maps, sourcemaps.write("."))) .pipe(gulpif(enabled.maps, sourcemaps.write(".")))
.pipe(chmod(644)) .pipe(gulpif(enabled.chmod, chmod(644)))
.pipe(gulp.dest(destination.js)) .pipe(gulp.dest(destination.js))
.pipe(gulpif(argv.livereload, livereload())); .pipe(gulpif(argv.livereload, livereload()));
}); });
/* Collection of scripts in src/scripts/tutti/ to merge into tutti.min.js */ /* 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 */ * 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.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.failCheck, plumber()))
.pipe(gulpif(enabled.maps, sourcemaps.init())) .pipe(gulpif(enabled.maps, sourcemaps.init()))
.pipe(concat("tutti.min.js")) .pipe(concat("tutti.min.js"))
.pipe(gulpif(enabled.uglify, uglify())) .pipe(gulpif(enabled.uglify, uglify()))
.pipe(gulpif(enabled.maps, sourcemaps.write("."))) .pipe(gulpif(enabled.maps, sourcemaps.write(".")))
.pipe(chmod(644)) .pipe(gulpif(enabled.chmod, chmod(644)))
.pipe(gulp.dest(destination.js)) .pipe(gulp.dest(destination.js))
.pipe(gulpif(argv.livereload, livereload())); .pipe(gulpif(argv.livereload, livereload()));
}); });
gulp.task('scripts_concat_markdown', function() {
gulp.src('src/scripts/markdown/**/*.js') /* Simply move these vendor scripts from node_modules. */
.pipe(gulpif(enabled.failCheck, plumber())) gulp.task('scripts_move_vendor', function(done) {
.pipe(gulpif(enabled.maps, sourcemaps.init()))
.pipe(concat("markdown.min.js")) let toMove = [
.pipe(gulpif(enabled.uglify, uglify())) 'node_modules/video.js/dist/video.min.js',
.pipe(gulpif(enabled.maps, sourcemaps.write("."))) ];
.pipe(chmod(644))
.pipe(gulp.dest(destination.js)) gulp.src(toMove)
.pipe(gulpif(argv.livereload, livereload())); .pipe(gulp.dest(destination.js + '/vendor/'));
done();
}); });
@@ -111,9 +133,9 @@ gulp.task('watch',function() {
gulp.watch('src/templates/**/*.pug',['templates']); gulp.watch('src/templates/**/*.pug',['templates']);
gulp.watch('src/scripts/*.js',['scripts']); gulp.watch('src/scripts/*.js',['scripts']);
gulp.watch('src/scripts/tutti/**/*.js',['scripts_concat_tutti']); 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. // Erases all generated files in output directories.
gulp.task('cleanup', function() { gulp.task('cleanup', function() {
var paths = []; var paths = [];
@@ -136,5 +158,5 @@ gulp.task('default', tasks.concat([
'templates', 'templates',
'scripts', 'scripts',
'scripts_concat_tutti', '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

@@ -4,23 +4,29 @@
"author": "Blender Institute", "author": "Blender Institute",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/armadillica/pillar.git" "url": "git://git.blender.org/pillar.git"
}, },
"devDependencies": { "devDependencies": {
"gulp": "~3.9.1", "gulp": "^3.9.1",
"gulp-autoprefixer": "~2.3.1", "gulp-autoprefixer": "^6.0.0",
"gulp-cached": "~1.1.0", "gulp-cached": "^1.1.1",
"gulp-chmod": "~1.3.0", "gulp-chmod": "^2.0.0",
"gulp-concat": "~2.6.0", "gulp-concat": "^2.6.1",
"gulp-if": "^2.0.1", "gulp-if": "^2.0.2",
"gulp-git": "~2.4.2", "gulp-git": "^2.8.0",
"gulp-livereload": "~3.8.1", "gulp-livereload": "^4.0.0",
"gulp-plumber": "~1.1.0", "gulp-plumber": "^1.2.0",
"gulp-pug": "~3.2.0", "gulp-pug": "^4.0.1",
"gulp-rename": "~1.2.2", "gulp-rename": "^1.4.0",
"gulp-sass": "~2.3.1", "gulp-sass": "^4.0.1",
"gulp-sourcemaps": "~1.6.0", "gulp-sourcemaps": "^2.6.4",
"gulp-uglify": "~1.5.3", "gulp-uglify-es": "^1.0.4",
"minimist": "^1.2.0" "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.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 # 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 # disabled on all endpoints. More info at WTF_CSRF_CHECK_DEFAULT in config.py
self.csrf = CSRFProtect(self) self.csrf = CSRFProtect(self)
@@ -479,10 +477,11 @@ class PillarServer(BlinkerCompatibleEve):
# Pillar-defined Celery task modules: # Pillar-defined Celery task modules:
celery_task_modules = [ celery_task_modules = [
'pillar.celery.tasks', 'pillar.celery.badges',
'pillar.celery.search_index_tasks',
'pillar.celery.file_link_tasks',
'pillar.celery.email_tasks', '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. # Allow Pillar extensions from defining their own Celery tasks.
@@ -704,6 +703,8 @@ class PillarServer(BlinkerCompatibleEve):
def finish_startup(self): def finish_startup(self):
self.log.info('Using MongoDB database %r', self.config['MONGO_DBNAME']) self.log.info('Using MongoDB database %r', self.config['MONGO_DBNAME'])
with self.app_context():
self.setup_db_indices()
self._config_celery() self._config_celery()
api.setup_app(self) api.setup_app(self)
@@ -760,6 +761,8 @@ class PillarServer(BlinkerCompatibleEve):
coll.create_index([('properties.status', pymongo.ASCENDING), coll.create_index([('properties.status', pymongo.ASCENDING),
('node_type', pymongo.ASCENDING), ('node_type', pymongo.ASCENDING),
('_created', pymongo.DESCENDING)]) ('_created', pymongo.DESCENDING)])
# Used for asset tags
coll.create_index([('properties.tags', pymongo.ASCENDING)])
coll = db['projects'] coll = db['projects']
# This index is used for statistics, and for fetching public 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') 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) my_log.debug('Fetching user info from %s', bid_url)
credentials = current_app.config['OAUTH_CREDENTIALS']['blender-id'] credentials = current_app.config['OAUTH_CREDENTIALS']['blender-id']

View File

@@ -123,6 +123,43 @@ users_schema = {
'allow_unknown': True, '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 # 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, # PillarExtension.name property) as the key, and are free to use whatever they want as value,
# but we suggest a dict for future extendability. # but we suggest a dict for future extendability.
@@ -339,11 +376,11 @@ tokens_schema = {
}, },
'token': { 'token': {
'type': 'string', 'type': 'string',
'required': False, 'required': True,
}, },
'token_hashed': { 'token_hashed': {
'type': 'string', 'type': 'string',
'required': True, 'required': False,
}, },
'expire_time': { 'expire_time': {
'type': 'datetime', 'type': 'datetime',
@@ -362,6 +399,13 @@ tokens_schema = {
'type': 'string', 'type': 'string',
}, },
}, },
# OAuth scopes granted to this token.
'oauth_scopes': {
'type': 'list',
'default': [],
'schema': {'type': 'string'},
}
} }
files_schema = { 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. # Use 'xy' as altargs to prevent + and / characters from appearing.
# We never have to b64decode the string anyway. # We never have to b64decode the string anyway.
token_bytes = prefix + base64.b64encode(random_bits, altchars=b'xy').strip(b'=') token = prefix + base64.b64encode(random_bits, altchars=b'xy').strip(b'=')
token = token_bytes.decode('ascii')
token_expiry = utcnow() + datetime.timedelta(days=days) token_expiry = utcnow() + datetime.timedelta(days=days)
token_data = store_token(user_id, token, token_expiry) return store_token(user_id, token.decode('ascii'), 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
def hash_password(password: str, salt: typing.Union[str, bytes]) -> str: def hash_password(password: str, salt: typing.Union[str, bytes]) -> str:

View File

@@ -1,17 +1,11 @@
import base64 import base64
import functools
import logging import logging
import urllib.parse
import pymongo.errors import pymongo.errors
import werkzeug.exceptions as wz_exceptions import werkzeug.exceptions as wz_exceptions
from bson import ObjectId
from flask import current_app, Blueprint, request from flask import current_app, Blueprint, request
import pillar.markdown from pillar.api.nodes import eve_hooks
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.utils import str2id, jsonify from pillar.api.utils import str2id, jsonify
from pillar.api.utils.authorization import check_permissions, require_login from pillar.api.utils.authorization import check_permissions, require_login
@@ -20,40 +14,6 @@ blueprint = Blueprint('nodes_api', __name__)
ROLES_FOR_SHARING = {'subscriber', 'demo'} ROLES_FOR_SHARING = {'subscriber', 'demo'}
def only_for_node_type_decorator(*required_node_type_names):
"""Returns a decorator that checks its first argument's node type.
If the node type is not of the required node type, returns None,
otherwise calls the wrapped function.
>>> deco = only_for_node_type_decorator('comment')
>>> @deco
... def handle_comment(node): pass
>>> deco = only_for_node_type_decorator('comment', 'post')
>>> @deco
... def handle_comment_or_post(node): pass
"""
# Convert to a set for efficient 'x in required_node_type_names' queries.
required_node_type_names = set(required_node_type_names)
def only_for_node_type(wrapped):
@functools.wraps(wrapped)
def wrapper(node, *args, **kwargs):
if node.get('node_type') not in required_node_type_names:
return
return wrapped(node, *args, **kwargs)
return wrapper
only_for_node_type.__doc__ = "Decorator, immediately returns when " \
"the first argument is not of type %s." % required_node_type_names
return only_for_node_type
@blueprint.route('/<node_id>/share', methods=['GET', 'POST']) @blueprint.route('/<node_id>/share', methods=['GET', 'POST'])
@require_login(require_roles=ROLES_FOR_SHARING) @require_login(require_roles=ROLES_FOR_SHARING)
def share_node(node_id): def share_node(node_id):
@@ -86,7 +46,68 @@ def share_node(node_id):
else: else:
return '', 204 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): def generate_and_store_short_code(node):
@@ -164,307 +185,35 @@ def create_short_code(node) -> str:
return short_code return short_code
def short_link_info(short_code):
"""Returns the short link info in a dict."""
short_link = urllib.parse.urljoin(
current_app.config['SHORT_LINK_BASE_URL'], short_code)
return {
'short_code': short_code,
'short_link': short_link,
}
def before_replacing_node(item, original):
check_permissions('nodes', original, 'PUT')
update_file_name(item)
def after_replacing_node(item, original):
"""Push an update to the Algolia index when a node item is updated. If the
project is private, prevent public indexing.
"""
from pillar.celery import search_index_tasks as index
projects_collection = current_app.data.driver.db['projects']
project = projects_collection.find_one({'_id': item['project']})
if project.get('is_private', False):
# Skip index updating and return
return
status = item['properties'].get('status', 'unpublished')
node_id = str(item['_id'])
if status == 'published':
index.node_save.delay(node_id)
else:
index.node_delete.delay(node_id)
def before_inserting_nodes(items):
"""Before inserting a node in the collection we check if the user is allowed
and we append the project id to it.
"""
from pillar.auth import current_user
nodes_collection = current_app.data.driver.db['nodes']
def find_parent_project(node):
"""Recursive function that finds the ultimate parent of a node."""
if node and 'parent' in node:
parent = nodes_collection.find_one({'_id': node['parent']})
return find_parent_project(parent)
if node:
return node
else:
return None
for item in items:
check_permissions('nodes', item, 'POST')
if 'parent' in item and 'project' not in item:
parent = nodes_collection.find_one({'_id': item['parent']})
project = find_parent_project(parent)
if project:
item['project'] = project['_id']
# Default the 'user' property to the current user.
item.setdefault('user', current_user.user_id)
def after_inserting_nodes(items):
for item in items:
# Skip subscriptions for first level items (since the context is not a
# node, but a project).
# TODO: support should be added for mixed context
if 'parent' not in item:
return
context_object_id = item['parent']
if item['node_type'] == 'comment':
nodes_collection = current_app.data.driver.db['nodes']
parent = nodes_collection.find_one({'_id': item['parent']})
# Always subscribe to the parent node
activity_subscribe(item['user'], 'node', item['parent'])
if parent['node_type'] == 'comment':
# If the parent is a comment, we provide its own parent as
# context. We do this in order to point the user to an asset
# or group when viewing the notification.
verb = 'replied'
context_object_id = parent['parent']
# Subscribe to the parent of the parent comment (post or group)
activity_subscribe(item['user'], 'node', parent['parent'])
else:
activity_subscribe(item['user'], 'node', item['_id'])
verb = 'commented'
elif item['node_type'] in PILLAR_NAMED_NODE_TYPES:
verb = 'posted'
activity_subscribe(item['user'], 'node', item['_id'])
else:
# Don't automatically create activities for non-Pillar node types,
# as we don't know what would be a suitable verb (among other things).
continue
activity_object_add(
item['user'],
verb,
'node',
item['_id'],
'node',
context_object_id
)
def deduct_content_type(node_doc, original=None):
"""Deduct the content type from the attached file, if any."""
if node_doc['node_type'] != 'asset':
log.debug('deduct_content_type: called on node type %r, ignoring', node_doc['node_type'])
return
node_id = node_doc.get('_id')
try:
file_id = ObjectId(node_doc['properties']['file'])
except KeyError:
if node_id is None:
# Creation of a file-less node is allowed, but updates aren't.
return
log.warning('deduct_content_type: Asset without properties.file, rejecting.')
raise wz_exceptions.UnprocessableEntity('Missing file property for asset node')
files = current_app.data.driver.db['files']
file_doc = files.find_one({'_id': file_id},
{'content_type': 1})
if not file_doc:
log.warning('deduct_content_type: Node %s refers to non-existing file %s, rejecting.',
node_id, file_id)
raise wz_exceptions.UnprocessableEntity('File property refers to non-existing file')
# Guess the node content type from the file content type
file_type = file_doc['content_type']
if file_type.startswith('video/'):
content_type = 'video'
elif file_type.startswith('image/'):
content_type = 'image'
else:
content_type = 'file'
node_doc['properties']['content_type'] = content_type
def nodes_deduct_content_type(nodes):
for node in nodes:
deduct_content_type(node)
def before_returning_node(node):
# Run validation process, since GET on nodes entry point is public
check_permissions('nodes', node, 'GET', append_allowed_methods=True)
# Embed short_link_info if the node has a short_code.
short_code = node.get('short_code')
if short_code:
node['short_link'] = short_link_info(short_code)['short_link']
def before_returning_nodes(nodes):
for node in nodes['_items']:
before_returning_node(node)
def node_set_default_picture(node, original=None):
"""Uses the image of an image asset or colour map of texture node as picture."""
if node.get('picture'):
log.debug('Node %s already has a picture, not overriding', node.get('_id'))
return
node_type = node.get('node_type')
props = node.get('properties', {})
content = props.get('content_type')
if node_type == 'asset' and content == 'image':
image_file_id = props.get('file')
elif node_type == 'texture':
# Find the colour map, defaulting to the first image map available.
image_file_id = None
for image in props.get('files', []):
if image_file_id is None or image.get('map_type') == 'color':
image_file_id = image.get('file')
else:
log.debug('Not setting default picture on node type %s content type %s',
node_type, content)
return
if image_file_id is None:
log.debug('Nothing to set the picture to.')
return
log.debug('Setting default picture for node %s to %s', node.get('_id'), image_file_id)
node['picture'] = image_file_id
def nodes_set_default_picture(nodes):
for node in nodes:
node_set_default_picture(node)
def before_deleting_node(node: dict):
check_permissions('nodes', node, 'DELETE')
def after_deleting_node(item):
from pillar.celery import search_index_tasks as index
index.node_delete.delay(str(item['_id']))
only_for_textures = only_for_node_type_decorator('texture')
@only_for_textures
def texture_sort_files(node, original=None):
"""Sort files alphabetically by map type, with colour map first."""
try:
files = node['properties']['files']
except KeyError:
return
# Sort the map types alphabetically, ensuring 'color' comes first.
as_dict = {f['map_type']: f for f in files}
types = sorted(as_dict.keys(), key=lambda k: '\0' if k == 'color' else k)
node['properties']['files'] = [as_dict[map_type] for map_type in types]
def textures_sort_files(nodes):
for node in nodes:
texture_sort_files(node)
def parse_markdown(node, original=None):
import copy
projects_collection = current_app.data.driver.db['projects']
project = projects_collection.find_one({'_id': node['project']}, {'node_types': 1})
# Query node type directly using the key
node_type = next(nt for nt in project['node_types']
if nt['name'] == node['node_type'])
# Create a copy to not overwrite the actual schema.
schema = copy.deepcopy(current_app.config['DOMAIN']['nodes']['schema'])
schema['properties'] = node_type['dyn_schema']
def find_markdown_fields(schema, node):
"""Find and process all makrdown validated fields."""
for k, v in schema.items():
if not isinstance(v, dict):
continue
if v.get('validator') == 'markdown':
# If there is a match with the validator: markdown pair, assign the sibling
# property (following the naming convention _<property>_html)
# the processed value.
if k in node:
html = pillar.markdown.markdown(node[k])
field_name = pillar.markdown.cache_field_name(k)
node[field_name] = html
if isinstance(node, dict) and k in node:
find_markdown_fields(v, node[k])
find_markdown_fields(schema, node)
return 'ok'
def parse_markdowns(items):
for item in items:
parse_markdown(item)
def setup_app(app, url_prefix): def setup_app(app, url_prefix):
global _tagged
cached = app.cache.memoize(timeout=300)
_tagged = cached(_tagged)
from . import patch from . import patch
patch.setup_app(app, url_prefix=url_prefix) patch.setup_app(app, url_prefix=url_prefix)
app.on_fetched_item_nodes += before_returning_node app.on_fetched_item_nodes += eve_hooks.before_returning_node
app.on_fetched_resource_nodes += before_returning_nodes app.on_fetched_resource_nodes += eve_hooks.before_returning_nodes
app.on_replace_nodes += before_replacing_node app.on_replace_nodes += eve_hooks.before_replacing_node
app.on_replace_nodes += parse_markdown app.on_replace_nodes += eve_hooks.parse_markdown
app.on_replace_nodes += texture_sort_files app.on_replace_nodes += eve_hooks.texture_sort_files
app.on_replace_nodes += deduct_content_type app.on_replace_nodes += eve_hooks.deduct_content_type
app.on_replace_nodes += node_set_default_picture app.on_replace_nodes += eve_hooks.node_set_default_picture
app.on_replaced_nodes += after_replacing_node app.on_replaced_nodes += eve_hooks.after_replacing_node
app.on_insert_nodes += before_inserting_nodes app.on_insert_nodes += eve_hooks.before_inserting_nodes
app.on_insert_nodes += parse_markdowns app.on_insert_nodes += eve_hooks.parse_markdowns
app.on_insert_nodes += nodes_deduct_content_type app.on_insert_nodes += eve_hooks.nodes_deduct_content_type
app.on_insert_nodes += nodes_set_default_picture app.on_insert_nodes += eve_hooks.nodes_set_default_picture
app.on_insert_nodes += textures_sort_files app.on_insert_nodes += eve_hooks.textures_sort_files
app.on_inserted_nodes += after_inserting_nodes 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_delete_item_nodes += eve_hooks.before_deleting_node
app.on_deleted_item_nodes += after_deleting_node app.on_deleted_item_nodes += eve_hooks.after_deleting_node
app.register_api_blueprint(blueprint, url_prefix=url_prefix) 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 return
# Remove all fields except public ones. # 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()): for field in list(user.keys()):
if field not in public_fields: if field not in public_fields:
del user[field] del user[field]

View File

@@ -1,9 +1,11 @@
import logging import logging
from eve.methods.get import get 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.api.utils.authorization import require_login
from pillar.auth import current_user from pillar.auth import current_user
@@ -15,7 +17,128 @@ blueprint_api = Blueprint('users_api', __name__)
@require_login() @require_login()
def my_info(): def my_info():
eve_resp, _, _, status, _ = get('users', {'_id': current_user.user_id}) 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 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: 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 typing
import bson import bson
from flask import g, current_app from flask import g, current_app, session
from flask import request from flask import request
from werkzeug import exceptions as wz_exceptions 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 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 """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 flask.g object, so that permissions and access to a resource can be defined
from it. 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. :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 # Trust a pre-existing g.current_user
if not force: if not force:
@@ -133,16 +133,22 @@ def validate_token(*, force=False):
oauth_subclient = '' oauth_subclient = ''
else: else:
# Check the session, the user might be logged in through Flask-Login. # 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 oauth_subclient = None
if not token: if not token:
# If no authorization headers are provided, we are getting a request # If no authorization headers are provided, we are getting a request
# from a non logged in user. Proceed accordingly. # from a non logged in user. Proceed accordingly.
log.debug('No authentication headers, so not logged in.') log.debug('No authentication headers, so not logged in.')
g.current_user = AnonymousUser() g.current_user = pillar.auth.AnonymousUser()
return False return False
return validate_this_token(token, oauth_subclient) is not None 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') tokens_coll = current_app.db('tokens')
token_hashed = hash_auth_token(token) 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}]} lookup = {'$or': [{'token': token}, {'token_hashed': token_hashed}]}
del_res = tokens_coll.delete_many(lookup) del_res = tokens_coll.delete_many(lookup)
log.debug('Removed token %r, matched %d documents', token, del_res.deleted_count) 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') tokens_coll = current_app.db('tokens')
token_hashed = hash_auth_token(token) 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}], lookup = {'$or': [{'token': token}, {'token_hashed': token_hashed}],
'is_subclient_token': True if is_subclient_token else {'$in': [False, None]}, 'is_subclient_token': True if is_subclient_token else {'$in': [False, None]},
'expire_time': {"$gt": utcnow()}} 'expire_time': {"$gt": utcnow()}}
@@ -229,8 +235,14 @@ def hash_auth_token(token: str) -> str:
return base64.b64encode(digest).decode('ascii') return base64.b64encode(digest).decode('ascii')
def store_token(user_id, token: str, token_expiry, oauth_subclient_id=False, def store_token(user_id,
org_roles: typing.Set[str] = frozenset()): 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. """Stores an authentication token.
:returns: the token document from MongoDB :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 = { token_data = {
'user': user_id, 'user': user_id,
'token_hashed': hash_auth_token(token), 'token': token,
'expire_time': token_expiry, 'expire_time': token_expiry,
} }
if oauth_subclient_id: if oauth_subclient_id:
token_data['is_subclient_token'] = True token_data['is_subclient_token'] = True
if org_roles: if org_roles:
token_data['org_roles'] = sorted(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) r, _, _, status = current_app.post_internal('tokens', token_data)

View File

@@ -1,5 +1,6 @@
import logging import logging
import functools import functools
import typing
from bson import ObjectId from bson import ObjectId
from flask import g from flask import g
@@ -12,8 +13,9 @@ CHECK_PERMISSIONS_IMPLEMENTED_FOR = {'projects', 'nodes', 'flamenco_jobs'}
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def check_permissions(collection_name, resource, method, append_allowed_methods=False, def check_permissions(collection_name: str, resource: dict, method: str,
check_node_type=None): append_allowed_methods=False,
check_node_type: typing.Optional[str] = None):
"""Check user permissions to access a node. We look up node permissions from """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. world to groups to users and match them with the computed user permissions.
If there is not match, we raise 403. 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 return allowed_methods
def has_permissions(collection_name, resource, method, append_allowed_methods=False, def has_permissions(collection_name: str, resource: dict, method: str,
check_node_type=None): append_allowed_methods=False,
check_node_type: typing.Optional[str] = None):
"""Check user permissions to access a node. We look up node permissions from """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. 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.groups: typing.List[str] = [] # NOTE: these are stringified object IDs.
self.group_ids: typing.List[bson.ObjectId] = [] self.group_ids: typing.List[bson.ObjectId] = []
self.capabilities: typing.Set[str] = set() 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 # Lazily evaluated
self._has_organizations: typing.Optional[bool] = None self._has_organizations: typing.Optional[bool] = None
@@ -56,6 +58,12 @@ class UserClass(flask_login.UserMixin):
user.email = db_user.get('email') or '' user.email = db_user.get('email') or ''
user.username = db_user.get('username') or '' user.username = db_user.get('username') or ''
user.full_name = db_user.get('full_name') 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 # Derived properties
user.objectid = str(user.user_id or '') 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) user = _load_user(oauth_token)
else: else:
user = UserClass(oauth_token) 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) flask_login.login_user(user, remember=True)
g.current_user = user g.current_user = user
user_authenticated.send(None) user_authenticated.send(None)

View File

@@ -1,8 +1,9 @@
import abc import abc
import attr
import json import json
import logging import logging
import typing
import attr
from rauth import OAuth2Service from rauth import OAuth2Service
from flask import current_app, url_for, request, redirect, session, Response 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)) id = attr.ib(validator=attr.validators.instance_of(str))
email = 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): class OAuthError(Exception):
@@ -127,6 +130,7 @@ class OAuthSignIn(metaclass=abc.ABCMeta):
class BlenderIdSignIn(OAuthSignIn): class BlenderIdSignIn(OAuthSignIn):
provider_name = 'blender-id' provider_name = 'blender-id'
scopes = ['email', 'badge']
def __init__(self): def __init__(self):
from urllib.parse import urljoin from urllib.parse import urljoin
@@ -140,12 +144,12 @@ class BlenderIdSignIn(OAuthSignIn):
client_secret=self.consumer_secret, client_secret=self.consumer_secret,
authorize_url=urljoin(base_url, 'oauth/authorize'), authorize_url=urljoin(base_url, 'oauth/authorize'),
access_token_url=urljoin(base_url, 'oauth/token'), access_token_url=urljoin(base_url, 'oauth/token'),
base_url='%s/api/' % base_url base_url=urljoin(base_url, 'api/'),
) )
def authorize(self): def authorize(self):
return redirect(self.service.get_authorize_url( return redirect(self.service.get_authorize_url(
scope='email', scope=' '.join(self.scopes),
response_type='code', response_type='code',
redirect_uri=self.get_callback_url()) redirect_uri=self.get_callback_url())
) )
@@ -159,7 +163,11 @@ class BlenderIdSignIn(OAuthSignIn):
session['blender_id_oauth_token'] = access_token session['blender_id_oauth_token'] = access_token
me = oauth_session.get('user').json() 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): class FacebookSignIn(OAuthSignIn):
@@ -189,7 +197,7 @@ class FacebookSignIn(OAuthSignIn):
me = oauth_session.get('me?fields=id,email').json() me = oauth_session.get('me?fields=id,email').json()
# TODO handle case when user chooses not to disclose en email # TODO handle case when user chooses not to disclose en email
# see https://developers.facebook.com/docs/graph-api/reference/user/ # 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): class GoogleSignIn(OAuthSignIn):
@@ -217,4 +225,4 @@ class GoogleSignIn(OAuthSignIn):
oauth_session = self.make_oauth_session() oauth_session = self.make_oauth_session()
me = oauth_session.get('userinfo').json() 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.operations import manager_operations
from pillar.cli.setup import manager_setup from pillar.cli.setup import manager_setup
from pillar.cli.elastic import manager_elastic from pillar.cli.elastic import manager_elastic
from . import badges
from pillar.cli import translations from pillar.cli import translations
@@ -24,3 +25,4 @@ manager.add_command("maintenance", manager_maintenance)
manager.add_command("setup", manager_setup) manager.add_command("setup", manager_setup)
manager.add_command("operations", manager_operations) manager.add_command("operations", manager_operations)
manager.add_command("elastic", manager_elastic) 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) 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='?', @manager_maintenance.option('-p', '--project', dest='proj_url', nargs='?',
help='Project URL') help='Project URL')
@manager_maintenance.option('-a', '--all', dest='all_projects', action='store_true', default=False, @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 import os.path
from os import getenv from os import getenv
from collections import defaultdict
import requests.certs import requests.certs
# Certificate file for communication with other systems. # Certificate file for communication with other systems.
@@ -29,6 +31,7 @@ DEBUG = False
SECRET_KEY = '' SECRET_KEY = ''
# Authentication token hashing key. If empty falls back to UTF8-encoded SECRET_KEY with a warning. # 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'' AUTH_TOKEN_HMAC_KEY = b''
# Authentication settings # Authentication settings
@@ -203,7 +206,17 @@ CELERY_BEAT_SCHEDULE = {
'schedule': 600, # every N seconds 'schedule': 600, # every N seconds
'args': ('gcs', 100) '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. # Mapping from user role to capabilities obtained by users with that role.
USER_CAPABILITIES = defaultdict(**{ USER_CAPABILITIES = defaultdict(**{

View File

@@ -163,8 +163,11 @@ class YouTube:
return html_module.escape('{youtube invalid YouTube ID/URL}') return html_module.escape('{youtube invalid YouTube ID/URL}')
src = f'https://www.youtube.com/embed/{youtube_id}?rel=0' src = f'https://www.youtube.com/embed/{youtube_id}?rel=0'
html = f'<iframe class="shortcode youtube" width="{width}" height="{height}" src="{src}"' \ html = f'<div class="embed-responsive embed-responsive-16by9">' \
f' frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>' f'<iframe class="shortcode youtube embed-responsive-item"' \
f' width="{width}" height="{height}" src="{src}"' \
f' frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>' \
f'</div>'
return html return html
@@ -225,12 +228,25 @@ class Attachment:
return self.render(file_doc, pargs, kwargs) return self.render(file_doc, pargs, kwargs)
def sdk_file(self, slug: str, node_properties: dict) -> pillarsdk.File: def sdk_file(self, slug: str, document: dict) -> pillarsdk.File:
"""Return the file document for the attachment with this slug.""" """Return the file document for the attachment with this slug."""
from pillar.web import system_util from pillar.web import system_util
attachments = node_properties.get('attachments', {}) # TODO (fsiddi) Make explicit what 'document' is.
# In some cases we pass the entire node or project documents, in other cases
# we pass node.properties. This should be unified at the level of do_markdown.
# For now we do a quick hack and first look for 'properties' in the doc,
# then we look for 'attachments'.
doc_properties = document.get('properties')
if doc_properties:
# We passed an entire document (all nodes must have 'properties')
attachments = doc_properties.get('attachments', {})
else:
# The value of document could have been defined as 'node.properties'
attachments = document.get('attachments', {})
attachment = attachments.get(slug) attachment = attachments.get(slug)
if not attachment: if not attachment:
raise self.NoSuchSlug(slug) raise self.NoSuchSlug(slug)

View File

@@ -1,6 +1,7 @@
# -*- encoding: utf-8 -*- # -*- encoding: utf-8 -*-
import base64 import base64
import contextlib
import copy import copy
import datetime import datetime
import json import json
@@ -10,11 +11,7 @@ import pathlib
import sys import sys
import typing import typing
import unittest.mock import unittest.mock
from urllib.parse import urlencode, urljoin
try:
from urllib.parse import urlencode
except ImportError:
from urllib.parse import urlencode
from bson import ObjectId, tz_util from bson import ObjectId, tz_util
@@ -186,7 +183,7 @@ class AbstractPillarTest(TestMinimal):
else: else:
self.ensure_project_exists() self.ensure_project_exists()
with self.app.test_request_context(): with self.app.app_context():
files_collection = self.app.data.driver.db['files'] files_collection = self.app.data.driver.db['files']
assert isinstance(files_collection, pymongo.collection.Collection) assert isinstance(files_collection, pymongo.collection.Collection)
@@ -327,15 +324,46 @@ class AbstractPillarTest(TestMinimal):
return user 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 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(): with self.app.test_request_context():
from pillar.api.utils import authentication as auth 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 return token_data
@@ -365,7 +393,7 @@ class AbstractPillarTest(TestMinimal):
return user_id return user_id
def create_node(self, node_doc): def create_node(self, node_doc) -> ObjectId:
"""Creates a node, returning its ObjectId. """ """Creates a node, returning its ObjectId. """
with self.app.test_request_context(): with self.app.test_request_context():
@@ -407,7 +435,7 @@ class AbstractPillarTest(TestMinimal):
"""Sets up Responses to mock unhappy validation flow.""" """Sets up Responses to mock unhappy validation flow."""
responses.add(responses.POST, 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'}, json={'status': 'fail'},
status=403) status=403)
@@ -415,7 +443,7 @@ class AbstractPillarTest(TestMinimal):
"""Sets up Responses to mock happy validation flow.""" """Sets up Responses to mock happy validation flow."""
responses.add(responses.POST, 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, json=BLENDER_ID_USER_RESPONSE,
status=200) status=200)

View File

@@ -1,6 +1,6 @@
"""Flask configuration file for unit testing.""" """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' SERVER_NAME = 'localhost.local'
PILLAR_SERVER_ENDPOINT = 'http://localhost.local/api/' 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 from pillar.web.nodes.forms import get_node_form
import pillar.web.nodes.attachments import pillar.web.nodes.attachments
from pillar.web.projects.routes import project_update_nodes_list from pillar.web.projects.routes import project_update_nodes_list
from pillar.web.projects.routes import project_navigation_links
log = logging.getLogger(__name__) 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.picture = get_file(post.picture, api=api)
post.url = url_for_node(node=post) post.url = url_for_node(node=post)
# Use the *_main_project.html template for the main blog
is_main_project = project_id == current_app.config['MAIN_PROJECT_ID']
main_project_template = '_main_project' if is_main_project else ''
main_project_template = '_main_project'
index_arch = 'archive' if archive else 'index' index_arch = 'archive' if archive else 'index'
template_path = f'nodes/custom/blog/{index_arch}{main_project_template}.html', template_path = f'nodes/custom/blog/{index_arch}.html',
if url: if url:
template_path = f'nodes/custom/post/view{main_project_template}.html',
post = Node.find_one({ post = Node.find_one({
'where': {'parent': blog._id, 'properties.url': url}, 'where': {'parent': blog._id, 'properties.url': url},
'embedded': {'node_type': 1, 'user': 1}, 'embedded': {'node_type': 1, 'user': 1},
@@ -95,6 +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) can_create_blog_posts = project.node_type_has_method('post', 'POST', api=api)
# Use functools.partial so we can later pass page=X. # Use functools.partial so we can later pass page=X.
is_main_project = project_id == current_app.config['MAIN_PROJECT_ID']
if is_main_project: if is_main_project:
url_func = functools.partial(url_for, 'main.main_blog_archive') url_func = functools.partial(url_for, 'main.main_blog_archive')
else: else:
@@ -112,24 +108,19 @@ def posts_view(project_id=None, project_url=None, url=None, *, archive=False, pa
else: else:
project.blog_archive_prev = None project.blog_archive_prev = None
title = 'blog_main' if is_main_project else 'blog' navigation_links = project_navigation_links(project, api)
pages = Node.all({
'where': {'project': project._id, 'node_type': 'page'},
'projection': {'name': 1}}, api=api)
return render_template( return render_template(
template_path, template_path,
blog=blog, blog=blog,
node=post, node=post, # node is used by the generic comments rendering (see custom/_scripts.pug)
posts=posts._items, posts=posts._items,
posts_meta=pmeta, posts_meta=pmeta,
more_posts_available=pmeta['total'] > pmeta['max_results'], more_posts_available=pmeta['total'] > pmeta['max_results'],
project=project, project=project,
title=title,
node_type_post=project.get_node_type('post'), node_type_post=project.get_node_type('post'),
can_create_blog_posts=can_create_blog_posts, can_create_blog_posts=can_create_blog_posts,
pages=pages._items, navigation_links=navigation_links,
api=api) api=api)

View File

@@ -94,6 +94,16 @@ def find_for_post(project, node):
url=node.properties.url) 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): def find_for_other(project, node):
"""Fallback: Assets, textures, and other node types. """Fallback: Assets, textures, and other node types.

View File

@@ -1,9 +1,10 @@
import functools
import logging import logging
import typing
from datetime import datetime from datetime import datetime
from datetime import date from datetime import date
import pillarsdk import pillarsdk
from flask import current_app
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField from wtforms import StringField
from wtforms import DateField from wtforms import DateField
@@ -17,6 +18,8 @@ from wtforms import DateTimeField
from wtforms import SelectMultipleField from wtforms import SelectMultipleField
from wtforms import FieldList from wtforms import FieldList
from wtforms.validators import DataRequired from wtforms.validators import DataRequired
from pillar import current_app
from pillar.web.utils import system_util from pillar.web.utils import system_util
from pillar.web.utils.forms import FileSelectField from pillar.web.utils.forms import FileSelectField
from pillar.web.utils.forms import CustomFormField from pillar.web.utils.forms import CustomFormField
@@ -44,6 +47,13 @@ def iter_node_properties(node_type):
yield prop_name, prop_schema, prop_fschema 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): def add_form_properties(form_class, node_type):
"""Add fields to a form based on the node and form schema provided. """Add fields to a form based on the node and form schema provided.
:type node_schema: dict :type node_schema: dict
@@ -60,7 +70,9 @@ def add_form_properties(form_class, node_type):
# Recursive call if detects a dict # Recursive call if detects a dict
field_type = schema_prop['type'] 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' assert prop_name == 'attachments'
field = attachments.attachment_form_group_create(schema_prop) field = attachments.attachment_form_group_create(schema_prop)
elif field_type == 'list': elif field_type == 'list':

View File

@@ -24,6 +24,7 @@ from pillar import current_app
from pillar.api.utils import utcnow from pillar.api.utils import utcnow
from pillar.web import system_util from pillar.web import system_util
from pillar.web import utils from pillar.web import utils
from pillar.web.nodes import finders
from pillar.web.utils.jstree import jstree_get_children from pillar.web.utils.jstree import jstree_get_children
import pillar.extension import pillar.extension
@@ -302,6 +303,51 @@ def view(project_url):
'header_video_node': header_video_node}) '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): def render_project(project, api, extra_context=None, template_name=None):
project.picture_square = utils.get_file(project.picture_square, api=api) project.picture_square = utils.get_file(project.picture_square, api=api)
project.picture_header = utils.get_file(project.picture_header, 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) extension_sidebar_links = current_app.extension_sidebar_links(project)
navigation_links = project_navigation_links(project, api)
return render_template(template_name, return render_template(template_name,
api=api, api=api,
project=project, project=project,
@@ -378,6 +426,7 @@ def render_project(project, api, extra_context=None, template_name=None):
show_project=True, show_project=True,
og_picture=project.picture_header, og_picture=project.picture_header,
activity_stream=activity_stream, activity_stream=activity_stream,
navigation_links=navigation_links,
extension_sidebar_links=extension_sidebar_links, extension_sidebar_links=extension_sidebar_links,
**extra_context) **extra_context)
@@ -447,16 +496,14 @@ def view_node(project_url, node_id):
# Append _theatre to load the proper template # Append _theatre to load the proper template
theatre = '_theatre' if theatre_mode else '' theatre = '_theatre' if theatre_mode else ''
navigation_links = project_navigation_links(project, api)
if node.node_type == 'page': 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', return render_template('nodes/custom/page/view_embed.html',
api=api, api=api,
node=node, node=node,
project=project, project=project,
pages=pages._items, navigation_links=navigation_links,
og_picture=og_picture,) og_picture=og_picture,)
extension_sidebar_links = current_app.extension_sidebar_links(project) extension_sidebar_links = current_app.extension_sidebar_links(project)
@@ -468,6 +515,7 @@ def view_node(project_url, node_id):
show_node=True, show_node=True,
show_project=False, show_project=False,
og_picture=og_picture, og_picture=og_picture,
navigation_links=navigation_links,
extension_sidebar_links=extension_sidebar_links) extension_sidebar_links=extension_sidebar_links)

View File

@@ -872,12 +872,6 @@
"code": 61930, "code": 61930,
"src": "fontawesome" "src": "fontawesome"
}, },
{
"uid": "31972e4e9d080eaa796290349ae6c1fd",
"css": "users",
"code": 59502,
"src": "fontawesome"
},
{ {
"uid": "c8585e1e5b0467f28b70bce765d5840c", "uid": "c8585e1e5b0467f28b70bce765d5840c",
"css": "clipboard-copy", "css": "clipboard-copy",
@@ -990,6 +984,30 @@
"code": 59394, "code": 59394,
"src": "entypo" "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", "uid": "053a214a098a9453877363eeb45f004e",
"css": "log-in", "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. # TODO: put those fields into a config var or module-level global.
return {'email': user.email, return {'email': user.email,
'full_name': user.full_name, 'full_name': user.full_name,
'username': user.username} 'username': user.username,
'badges_html': (user.badges and user.badges.html) or ''}
def setup_app(app): def setup_app(app):

View File

@@ -48,6 +48,10 @@ def oauth_authorize(provider):
@blueprint.route('/oauth/<provider>/authorized') @blueprint.route('/oauth/<provider>/authorized')
def oauth_callback(provider): 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: if current_user.is_authenticated:
return redirect(url_for('main.homepage')) return redirect(url_for('main.homepage'))
@@ -65,6 +69,16 @@ def oauth_callback(provider):
user_info = {'id': oauth_user.id, 'email': oauth_user.email, 'full_name': ''} user_info = {'id': oauth_user.id, 'email': oauth_user.email, 'full_name': ''}
db_user = find_user_in_db(user_info, provider=provider) db_user = find_user_in_db(user_info, provider=provider)
db_id, status = upsert_user(db_user) db_id, status = upsert_user(db_user)
# 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) token = generate_and_store_token(db_id)
# Login user # Login user

View File

@@ -62,7 +62,7 @@ def jstree_get_children(node_id, project_id=None):
'where': { 'where': {
'$and': [ '$and': [
{'node_type': {'$regex': '^(?!attract_)'}}, {'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; 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)); }(jQuery));

View File

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

View File

@@ -66,12 +66,9 @@ function containerResizeY(window_height){
var project_container = document.getElementById('project-container'); var project_container = document.getElementById('project-container');
var container_offset = project_container.offsetTop; 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 = window_height - container_offset.top;
var container_height_wheader = window_height - container_offset.top - nav_header_height; var container_height_wheader = window_height - container_offset;
var window_height_minus_nav = window_height - nav_header_height - 1; // 1 is border width var window_height_minus_nav = window_height - container_offset;
$('#project_context-header').width($('#project_context-container').width());
if ($(window).width() > 768) { if ($(window).width() > 768) {
$('#project-container').css( $('#project-container').css(
@@ -79,13 +76,14 @@ function containerResizeY(window_height){
'height': window_height_minus_nav + 'px'} 'height': window_height_minus_nav + 'px'}
); );
$('#project_nav-container, #project_tree, .project_split').css( $('#project_nav-container, #project_tree').css(
{'max-height': (window_height_minus_nav - 50) + 'px', {'max-height': (window_height_minus_nav) + 'px',
'height': (window_height_minus_nav - 50) + 'px'} 'height': (window_height_minus_nav) + 'px'}
); );
if (container_height > parseInt($('#project-container').css("min-height"))) { if (container_height > parseInt($('#project-container').css("min-height"))) {
if (typeof projectTree !== "undefined"){ if (typeof projectTree !== "undefined"){
$(projectTree).css( $(projectTree).css(
{'max-height': container_height_wheader + 'px', {'max-height': container_height_wheader + 'px',
'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); parentDiv.after(commentForm);
// document.getElementById('comment_field').focus(); // document.getElementById('comment_field').focus();
$(commentField).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'); $('.comment-reply-field').addClass('filled');
}); });
@@ -59,10 +54,6 @@ $(document).on('click','body .comment-action-cancel',function(e){
delete commentField.dataset.originalParentId; delete commentField.dataset.originalParentId;
$(commentField).val(''); $(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-reply-field').removeClass('filled');
$('.comment-container').removeClass('is-replying'); $('.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 left: 0
width: $sidebar-width width: $sidebar-width
height: 100% height: 100%
background-color: $color-background-nav
display: flex display: flex
flex-direction: column flex-direction: column
> ul > li > .navbar-item
padding-top: 10px
padding-bottom: 10px
background: red
.dropdown .dropdown
min-width: $sidebar-width min-width: $sidebar-width
.dropdown-menu .dropdown-menu
top: initial top: initial
bottom: 3px bottom: 3px
@@ -159,7 +164,7 @@ nav.sidebar
li a li a
justify-content: flex-start justify-content: flex-start
ul > ul
width: 100% width: 100%
margin: 0 margin: 0
padding: 0 padding: 0
@@ -172,25 +177,11 @@ nav.sidebar
a.navbar-item, button a.navbar-item, button
display: flex display: flex
color: $color-text-light-hint
font-size: 1.5em
align-items: center align-items: center
justify-content: center justify-content: center
padding: 10px 0
background: transparent background: transparent
border: none border: none
width: 100% width: 100%
text-decoration: none
&:hover
color: $color-text-light-primary
&:active
outline: none
&.cloud
i
position: relative
left: -4px
a.dropdown-toggle a.dropdown-toggle
padding: 0 padding: 0
@@ -408,3 +399,68 @@ nav.sidebar
top: -1px top: -1px
left: -19px left: -19px
z-index: 1 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-width-max: 710px
.comments-container .comments-container
max-width: $comments-width-max
position: relative position: relative
width: 100%
#comments-reload #comments-reload
text-align: center text-align: center
@@ -30,7 +32,7 @@ $comments-width-max: 710px
.comment-reply-container .comment-reply-container
display: flex display: flex
position: relative 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 transition: background-color 150ms ease-in-out, padding 150ms ease-in-out, margin 150ms ease-in-out
&.comment-linked &.comment-linked
@@ -194,8 +196,6 @@ $comments-width-max: 710px
cursor: pointer cursor: pointer
font-family: 'pillar-font' font-family: 'pillar-font'
height: 25px height: 25px
position: relative
top: 4px
width: 16px width: 16px
.comment-action-rating.up .comment-action-rating.up
@@ -284,10 +284,11 @@ $comments-width-max: 710px
color: $color-success color: $color-success
&.is-replying &.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 &.is-replying+.comment-reply-container
box-shadow: inset 5px 0 0 $color-primary box-shadow: -5px 0 0 $color-primary
margin-left: 0 margin-left: 0
padding-left: 55px padding-left: 55px
@@ -314,9 +315,6 @@ $comments-width-max: 710px
color: $color-success color: $color-success
.comment-reply .comment-reply
&-container
background-color: $color-background
/* Little gravatar icon on the left */ /* Little gravatar icon on the left */
&-avatar &-avatar
img img
@@ -333,7 +331,7 @@ $comments-width-max: 710px
width: 100% width: 100%
&-field &-field
background-color: $color-background-dark background-color: $color-background-light
border-radius: 3px border-radius: 3px
box-shadow: inset 0 0 2px 0 rgba(darken($color-background-dark, 20%), .5) box-shadow: inset 0 0 2px 0 rgba(darken($color-background-dark, 20%), .5)
display: flex display: flex
@@ -342,6 +340,7 @@ $comments-width-max: 710px
textarea textarea
+node-details-description +node-details-description
background-color: $color-background-light
border-bottom-right-radius: 0 border-bottom-right-radius: 0
border-top-right-radius: 0 border-top-right-radius: 0
border: none border: none
@@ -376,7 +375,6 @@ $comments-width-max: 710px
&.filled &.filled
textarea textarea
background-color: $color-background-light
border-bottom: thin solid $color-background border-bottom: thin solid $color-background
&:focus &:focus
@@ -453,12 +451,17 @@ $comments-width-max: 710px
transition: background-color 150ms ease-in-out, color 150ms ease-in-out transition: background-color 150ms ease-in-out, color 150ms ease-in-out
width: 100px width: 100px
// The actual button for submitting the comment.
button.comment-action-submit button.comment-action-submit
align-items: center
background: transparent background: transparent
border: none border: none
border-top-left-radius: 0 border-top-left-radius: 0
border-bottom-left-radius: 0 border-bottom-left-radius: 0
color: $color-success color: $color-success
cursor: pointer
display: flex
justify-content: center
flex-direction: column flex-direction: column
height: 100% height: 100%
position: relative position: relative
@@ -466,8 +469,12 @@ $comments-width-max: 710px
white-space: nowrap white-space: nowrap
width: 100% width: 100%
&:hover
background: rgba($color-success, .1)
&:focus &:focus
background: lighten($color-success, 10%) background: lighten($color-success, 10%)
color: $white
&.submitting &.submitting
color: $color-info 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-body: 'Roboto' !default
$font-headings: 'Lato' !default $font-headings: 'Lato' !default
$font-size: 14px !default $font-size: 14px !default
$font-size-xs: .75rem
$font-size-xxs: .65rem
$color-text: #4d4e53 !default $color-text: #4d4e53 !default
$color-text-dark: $color-text !default $color-text-dark: $color-text !default
$color-text-dark-primary: #646469 !default $color-text-dark-primary: #646469 !default
$color-text-dark-secondary: #9E9FA2 !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-secondary: rgba($color-text-light, .54) !default
$color-text-light-hint: rgba($color-text-light, .38) !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-light: hsl(hue($color-primary), 30%, 90%) !default
$color-primary-dark: hsl(hue($color-primary), 80%, 30%) !default $color-primary-dark: hsl(hue($color-primary), 80%, 30%) !default
$color-primary-accent: hsl(hue($color-primary), 100%, 50%) !default $color-primary-accent: hsl(hue($color-primary), 100%, 50%) !default
$primary-accent: #0bd
$color-secondary: #f42942 !default $color-secondary: #f42942 !default
$color-secondary-light: hsl(hue($color-secondary), 30%, 90%) !default $color-secondary-light: hsl(hue($color-secondary), 30%, 90%) !default
@@ -96,16 +98,17 @@ $screen-xs-max: $screen-sm-min - 1 !default
$screen-sm-max: $screen-md-min - 1 !default $screen-sm-max: $screen-md-min - 1 !default
$screen-md-max: $screen-lg-min - 1 !default $screen-md-max: $screen-lg-min - 1 !default
$sidebar-width: 50px !default $sidebar-width: 40px !default
/* Project specifics */ /* Project specifics */
$project_nav-width: 250px !default $project_nav-width: 250px !default
$project-sidebar-width: 50px !default $project_nav-width-xl: $project_nav-width * 1.4 !default
$project_header-height: 50px !default $project_nav-width-lg: $project_nav-width * 1.2 !default
$project_footer-height: 30px !default $project_nav-width-md: $project_nav-width
$project_nav-width-sm: $project_nav-width * 0.8 !default
$navbar-height: 50px !default $project_nav-width-xs: 100% !default
$navbar-backdrop-height: 600px !default $project-sidebar-width: 40px !default
$project_header-height: 40px !default
$node-type-asset_image: #e87d86 !default $node-type-asset_image: #e87d86 !default
$node-type-asset_file: #CC91C7 !default $node-type-asset_file: #CC91C7 !default
@@ -125,3 +128,39 @@ $z-index-base: 13 !default
@media (min-width: $screen-lg-min) @media (min-width: $screen-lg-min)
width: 1270px 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 #node-overlay
#error-container #error-container
position: fixed
top: $navbar-height
align-items: flex-start align-items: flex-start
position: fixed
top: $nav-link-height
#error-box #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-left-radius: 0
border-top-right-radius: 0 border-top-right-radius: 0
box-shadow: 0 0 25px rgba(black, .1), 0 0 50px rgba(black, .1)
position: relative position: relative
width: 100% width: 100%

View File

@@ -9,7 +9,6 @@
color: $color-primary color: $color-primary
cursor: pointer cursor: pointer
float: right float: right
font-family: $font-body
height: initial height: initial
margin: 0 margin: 0
padding: 8px 10px 0 10px padding: 8px 10px 0 10px
@@ -25,13 +24,16 @@
color: $color-secondary color: $color-secondary
#notifications-toggle #notifications-toggle
color: $color-text
cursor: pointer cursor: pointer
font-size: 1.5em
position: relative position: relative
user-select: none user-select: none
> i:before > i:before
content: '\e815' content: '\e815'
font-size: 1.3em
position: relative
top: 2px
&.has-notifications &.has-notifications
> i:before > i:before
@@ -46,10 +48,10 @@
border-color: transparent transparent $color-background transparent border-color: transparent transparent $color-background transparent
border-style: solid border-style: solid
border-width: 0 8px 8px 8px border-width: 0 8px 8px 8px
bottom: -15px bottom: -10px
height: 0 height: 0
position: absolute position: absolute
right: 22px right: 7px
visibility: hidden visibility: hidden
width: 0 width: 0

View File

@@ -1,20 +1,4 @@
body.organizations 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 #item-details
.organization .organization
label label

View File

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

View File

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

View File

@@ -92,19 +92,6 @@ ul.sharing-users-list
&:hover &:hover
color: lighten($color-danger, 10%) 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 .sharing-users-search
.disabled .disabled
@@ -162,24 +149,26 @@ ul.list-generic
list-style: none list-style: none
> li > li
padding: 5px 0
display: flex
align-items: center align-items: center
border-top: thin solid $color-background border-top: thin solid $color-background
display: flex
padding: 5px 0
&:first-child &:first-child
border-top: none border-top: none
&:hover .item a &:hover .item a
color: $color-primary color: $primary
a a
flex: 1 flex: 1
&.active
color: $primary !important
font-weight: bold
.actions .actions
margin-left: auto margin-left: auto
.btn
font-size: .7em
span span
color: $color-text-dark-secondary 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 .search-hit-name
font-weight: 400 font-weight: 400
padding-top: 8px padding-top: 8px
color: $color-primary-dark color: $primary
.search-hit .search-hit
padding: 0 padding: 0
@@ -29,14 +29,13 @@ $search-hit-width_grid: 100px
font: font:
size: .9em size: .9em
weight: 400 weight: 400
family: $font-body
style: initial style: initial
width: 100% width: 100%
+text-overflow-ellipsis +text-overflow-ellipsis
+clearfix +clearfix
& em & em
color: $color-primary-dark color: $primary
font-style: normal font-style: normal
&:hover &:hover
@@ -71,7 +70,7 @@ $search-hit-width_grid: 100px
min-width: 350px min-width: 350px
border-bottom-left-radius: 3px border-bottom-left-radius: 3px
border-bottom-right-radius: 3px border-bottom-right-radius: 3px
border-top: 3px solid lighten($color-primary, 5%) border-top: 3px solid lighten($primary, 5%)
overflow: hidden overflow: hidden
.tt-suggestion .tt-suggestion
@@ -93,222 +92,54 @@ $search-hit-width_grid: 100px
&.tt-cursor:hover .search-hit &.tt-cursor:hover .search-hit
background-color: lighten($color-background, 5%) background-color: lighten($color-background, 5%)
#search-container .search-list
display: flex width: 50%
min-height: 600px
background-color: white
+media-lg .embed-responsive
padding-left: 0 width: 100px
padding-right: 0 min-width: 100px
#search-sidebar .card-deck.card-deck-vertical
width: 20% .card
background-color: $color-background-light flex-wrap: initial
+media-lg .search-settings
border-top-left-radius: 3px width: 30%
.card-deck.card-deck-vertical
.card .embed-responsive
max-width: 80px
input.search-field input.search-field
background-color: $color-background-nav-dark
font-size: 1.1em
color: white
margin-bottom: 10px
border: none border: none
border-bottom: 2px solid rgba($color-primary, .2) border-bottom: 2px solid rgba($primary, .2)
border-radius: 0 border-radius: 0
width: 100% width: 100%
padding: 5px 15px
height: 50px
transition: border 100ms ease-in-out transition: border 100ms ease-in-out
&::placeholder &::placeholder
color: $color-text-dark-secondary color: $color-text-dark-secondary
&:placeholder-shown &:placeholder-shown
border-bottom-color: $color-primary border-bottom-color: $primary
&:focus &:focus
outline: none outline: none
border: none border: none
border-bottom: 2px solid lighten($color-primary, 5%) border-bottom: 2px solid lighten($primary, 5%)
.search-list-filters .search-details
padding: width: 70%
left: 10px
right: 10px
.panel.panel-default .container-fluid .col-md-8
margin-bottom: 10px flex: 1
border-radius: 3px max-width: 100%
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% 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
overflow-y: auto
#hits
position: relative
width: 100%
#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 #search-details
position: relative position: relative
width: 40%
border-left: 2px solid darken(white, 3%)
#search-hit-container #search-hit-container
position: absolute // for scrollbars position: absolute // for scrollbars
width: 100%
overflow-y: auto overflow-y: auto
#error_container #error_container
@@ -322,6 +153,7 @@ $search-hit-width_grid: 100px
color: $color-danger color: $color-danger
text-align: center text-align: center
#search-container
#node-container #node-container
width: 100% width: 100%
max-width: 100% max-width: 100%
@@ -416,9 +248,7 @@ $search-hit-width_grid: 100px
&.texture &.texture
.texture-title .texture-title
font: font-size: 2em
size: 2em
family: $font-body
padding: 15px 10px 10px 15px padding: 15px 10px 10px 15px
.node-row .node-row
background: white background: white
@@ -476,215 +306,88 @@ $search-hit-width_grid: 100px
button button
width: 100% width: 100%
.search-hit .search-project
float: left li.project
box-shadow: none display: none
border: thin solid transparent
border-top-color: darken(white, 8%)
border-left: 3px solid transparent
color: $color-background-nav #search-sidebar
.toggleRefine
display: block
padding-left: 7px
color: $color-text-dark
text-transform: capitalize
width: 100% &:hover
text-decoration: none
color: $primary
&.refined
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 position: relative
margin: 0 left: -7px
padding: 7px 10px 7px 10px font-size: .9em
span
&:before
/* empty circle */
content: '\e82c'
font-family: 'pillar-font'
position: relative
left: -7px
font-size: .9em
.search-list-stats
color: $color-text-dark-hint
padding: 10px 15px 0 15px
text-align: center
font-size: .9em
+clearfix +clearfix
&:first-child .search-pagination
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 text-align: center
overflow: hidden list-style-type: none
+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
&:hover
cursor: pointer
text-decoration: underline
em
color: darken($color-primary, 15%)
font-style: normal
.search-hit-ribbon
+ribbon
right: -30px
top: 5px
span
font-size: 60%
margin: 1px 0
padding: 2px 35px
.search-hit-meta
position: relative
font-size: .9em
color: $color-text-dark-secondary
background-color: initial
padding: 3px 0 0 0
text-decoration: none
+text-overflow-ellipsis
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
&.when
margin: 0 3px
float: right
display: block
+media-lg
display: block
+media-md
display: block
+media-sm
display: none
+media-xs
display: none
&.context
margin: 0 margin: 0
float: right padding: 0
display: none 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 &:hover
cursor: pointer color: $color-text-dark-primary
.search-hit-name-user
color: $color-primary
&.users &.disabled
em opacity: .6
font-style: normal
color: $color-primary
.search-hit-name &.active a
font-size: 1.2em color: $color-text-dark-primary
font-weight: bold
small
margin-left: 5px
color: $color-text-dark-secondary
.search-hit-roles
font-size: .9em
color: $color-text-dark-secondary
margin-left: 15px
.view-grid .view-grid
display: flex display: flex
@@ -706,13 +409,13 @@ $search-hit-width_grid: 100px
transition: border-color 150ms ease-in-out transition: border-color 150ms ease-in-out
&.active &.active
background-color: $color-primary background-color: $primary
border-color: $color-primary border-color: $primary
.search-hit-name .search-hit-name
font-weight: 500 font-weight: 500
color: white color: white
background-color: $color-primary background-color: $primary
.search-hit-name .search-hit-name
font-size: .9em font-size: .9em
@@ -776,5 +479,5 @@ $search-hit-width_grid: 100px
&.active &.active
color: white color: white
background-color: $color-primary background-color: $primary
border-color: transparent border-color: transparent

View File

@@ -67,138 +67,6 @@
&:hover &:hover
background-color: lighten($provider-color-google, 7%) 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 #user-edit-container
padding: 15px padding: 15px

View File

@@ -26,7 +26,6 @@
display: inline-flex display: inline-flex
align-items: center align-items: center
justify-content: center justify-content: center
font-family: $font-body
padding: 5px 12px padding: 5px 12px
border-radius: $roundness border-radius: $roundness
@@ -83,6 +82,15 @@
text-shadow: none 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) @mixin overlay($from-color, $from-percentage, $to-color, $to-percentage)
position: absolute position: absolute
top: 0 top: 0
@@ -122,24 +130,16 @@
transform: translate(-50%, -50%) transform: translate(-50%, -50%)
=input-generic =input-generic
padding: 5px 5px 5px 0
color: $color-text-dark 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 background-color: transparent
transition: border-color 150ms ease-in-out, box-shadow 150ms ease-in-out
&:hover &:hover
border-bottom-color: $color-background border-bottom-color: $color-background
&:focus &:focus
outline: 0 outline: 0
border: thin solid transparent border-color: $primary
border-bottom-color: $color-primary box-shadow: none
box-shadow: 0 1px 0 0 $color-primary
=label-generic =label-generic
color: $color-text-dark-primary color: $color-text-dark-primary
@@ -170,17 +170,25 @@
/* Small but wide: phablets, iPads /* Small but wide: phablets, iPads
** Menu is collapsed, columns stack, no brand */ ** Menu is collapsed, columns stack, no brand */
=media-sm =media-sm
@media (min-width: #{$screen-tablet}) and (max-width: #{$screen-desktop - 1px}) @include media-breakpoint-up(sm)
@content @content
/* Tablets portrait. /* Tablets portrait.
** Menu is expanded, but columns stack, brand is shown */ ** Menu is expanded, but columns stack, brand is shown */
=media-md =media-md
@media (min-width: #{$screen-desktop}) @include media-breakpoint-up(md)
@content @content
=media-lg =media-lg
@media (min-width: #{$screen-lg-desktop}) @include media-breakpoint-up(lg)
@content
=media-xl
@include media-breakpoint-up(xl)
@content
=media-xxl
@include media-breakpoint-up(xxl)
@content @content
=media-print =media-print
@@ -352,17 +360,15 @@
=node-details-description =node-details-description
+clearfix +clearfix
color: darken($color-text-dark, 5%) color: $color-text
font: font-size: 1.25em
family: $font-body
weight: 300
size: 1.2em
word-break: break-word word-break: break-word
+media-xs +media-xs
font-size: 1.1em font-size: 1.1em
/* Style links without a class. Usually regular
* links in a comment or node description. */
a:not([class]) a:not([class])
color: $color-text-dark-primary color: $color-text-dark-primary
text-decoration: underline text-decoration: underline
@@ -375,11 +381,6 @@
line-height: 1.5em line-height: 1.5em
word-wrap: break-word word-wrap: break-word
h1, h2, h3, h4, h5, h6
padding:
top: 20px
right: 20px
blockquote blockquote
background-color: lighten($color-background-light, 5%) background-color: lighten($color-background-light, 5%)
box-shadow: inset 5px 0 0 $color-background box-shadow: inset 5px 0 0 $color-background
@@ -400,10 +401,10 @@
img, img,
p img, p img,
ul li img ul li img
@extend .d-block
@extend .mx-auto
@extend .my-3
max-width: 100% max-width: 100%
padding:
bottom: 25px
top: 25px
&.emoji &.emoji
display: inline-block display: inline-block
@@ -416,25 +417,13 @@
font-size: 1.5em font-size: 1.5em
/* e.g. YouTube embed */ /* e.g. YouTube embed */
iframe iframe, video
height: auto
margin: 15px auto
max-width: 100% max-width: 100%
min-height: 500px @extend .mx-auto
width: 100%
+media-sm .embed-responsive,
iframe video
min-height: 314px @extend .my-3
+media-xs
iframe
min-height: 314px
iframe[src^="https://www.youtube"]
+media-xs
iframe
min-height: 420px
min-height: 500px
iframe[src^="https://w.soundcloud"] iframe[src^="https://w.soundcloud"]
min-height: auto min-height: auto
@@ -507,28 +496,24 @@
=ribbon =ribbon
background-color: $color-success background-color: $color-success
cursor: default border: thin dashed rgba(white, .5)
color: white
pointer-events: none
font-size: 70%
overflow: hidden overflow: hidden
white-space: nowrap
position: absolute position: absolute
right: -40px right: -40px
top: 10px top: 10px
-webkit-transform: rotate(45deg)
-moz-transform: rotate(45deg)
-ms-transform: rotate(45deg)
-o-transform: rotate(45deg)
transform: rotate(45deg) transform: rotate(45deg)
white-space: nowrap
span span
border: thin dashed rgba(white, .5)
color: white
display: block display: block
font-size: 70%
margin: 1px 0 margin: 1px 0
padding: 3px 50px padding: 3px 50px
text:
align: center .ribbon
transform: uppercase +ribbon
@mixin text-background($text-color, $background-color, $roundness, $padding) @mixin text-background($text-color, $background-color, $roundness, $padding)
border-radius: $roundness border-radius: $roundness
@@ -568,9 +553,7 @@
/* Bootstrap's img-responsive class */ /* Bootstrap's img-responsive class */
=img-responsive =img-responsive
display: block @extend .img-fluid
max-width: 100%
height: auto
/* Set the color for a specified property /* Set the color for a specified property
* 1: $property: e.g. background-color * 1: $property: e.g. background-color
@@ -642,9 +625,7 @@
#{$property}: $color-status-review #{$property}: $color-status-review
=sidebar-button-active =sidebar-button-active
background-color: $color-background-nav color: $primary
box-shadow: inset 2px 0 0 $color-primary
color: white
.flash-on .flash-on
background-color: lighten($color-success, 50%) !important background-color: lighten($color-success, 50%) !important
@@ -660,3 +641,52 @@
transition: all 1s ease-out transition: all 1s ease-out
img img
transition: all 1s ease-out 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 _config
@import _utils @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 _comments
@import _error @import _notifications
@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%
#blog_post-edit-form #blog_post-edit-form
padding: 20px padding: 20px
.form-group .form-group
position: relative
margin: 0 auto 30px auto
font-family: $font-body
input, textarea, select input, textarea, select
+input-generic +input-generic
@@ -95,7 +120,6 @@
margin-bottom: 15px margin-bottom: 15px
border-top: thin solid $color-text-dark-hint border-top: thin solid $color-text-dark-hint
.form-group.description, .form-group.description,
.form-group.summary, .form-group.summary,
.form-group.content .form-group.content
@@ -163,14 +187,10 @@
color: transparent color: transparent
#blog_post-create-container,
#blog_post-edit-container
padding: 25px
#blog_index-container,
#blog_post-create-container, #blog_post-create-container,
#blog_post-edit-container #blog_post-edit-container
+container-box +container-box
padding: 25px
width: 75% width: 75%
+media-xs +media-xs
@@ -185,133 +205,6 @@
+media-lg +media-lg
width: 100% 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 #blog_post-edit-form
padding: 0 padding: 0
@@ -346,294 +239,3 @@
.form-upload-file-meta .form-upload-file-meta
width: initial width: initial
#blog_post-edit-title
padding: 0
color: $color-text
font:
size: 1.8em
weight: 300
margin: 0 20px 15px 0
#blog_index-sidebar
width: 25%
padding: 0 15px
+media-xs
width: 100%
clear: both
display: block
margin-top: 25px
+media-sm
width: 40%
+media-md
width: 30%
+media-lg
width: 25%
.button-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 $pillar-font-path: "../font" !default
/* Font properties. */
@font-face @font-face
font-family: 'pillar-font' 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-weight: normal
font-style: normal font-style: normal
@@ -17,23 +20,99 @@ $pillar-font-path: "../font" !default
width: 1em width: 1em
margin-right: .2em margin-right: .2em
text-align: center text-align: center
/* opacity: .8;
/* For safety - reset parent styles, that can break glyph codes
font-variant: normal font-variant: normal
text-transform: none text-transform: none
/* fix buttons height, for twitter bootstrap
line-height: 1em line-height: 1em
/* Animation center compensation - margins should be symmetric
/* remove if not needed
margin-left: .2em 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 -webkit-font-smoothing: antialiased
-moz-osx-font-smoothing: grayscale -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 .pi-collection-plus:before
content: '\e800' content: '\e800'
@@ -430,6 +509,11 @@ $pillar-font-path: "../font" !default
/* '' /* ''
.pi-speed:before
content: '\e84f'
/* ''
.pi-attention:before .pi-attention:before
content: '\e850' content: '\e850'
@@ -580,11 +664,6 @@ $pillar-font-path: "../font" !default
/* '' /* ''
.pi-users:before
content: '\e86e'
/* ''
.pi-flamenco:before .pi-flamenco:before
content: '\e86f' content: '\e86f'
@@ -605,6 +684,11 @@ $pillar-font-path: "../font" !default
/* '' /* ''
.pi-users:before
content: '\e873'
/* ''
.pi-pause:before .pi-pause:before
content: '\f00e' 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 .pi-newspaper:before
content: '\f1ea' 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 _config
@import _utils @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 */ /* Generic styles (comments, notifications, etc) come from base.css */
@import _notifications
@import _comments
@import _project @import _project
@import _project-sharing @import _project-sharing
@import _project-dashboard @import _project-dashboard
@import _user @import _user
@import _search
@import _organizations @import _organizations
@import _search
/* services, about, etc */ /* services, about, etc */
@import _pages @import _pages

View File

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

View File

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

View File

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

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... span Add files...
input(type='file', name='file', multiple='') input(type='file', name='file', multiple='')
button.btn.btn-primary.start(type='submit') button.btn.btn-outline-primary.start(type='submit')
i.pi-upload 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 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 i.pi-trash
span Delete span Delete

View File

@@ -23,7 +23,7 @@ script#template-upload(type="text/x-tmpl").
</button> </button>
{% } %} {% } %}
{% if (!i) { %} {% if (!i) { %}
<button class="btn btn-warning cancel"> <button class="btn btn-outline-secondary cancel">
<i class="ion-close-round"></i> <i class="ion-close-round"></i>
<span>Cancel</span> <span>Cancel</span>
</button> </button>
@@ -61,7 +61,7 @@ script#template-download(type="text/x-tmpl").
</td> </td>
<td> <td>
{% if (file.deleteUrl) { %} {% 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> <i class="ion-trash-b"></i>
<span>Delete</span> <span>Delete</span>
</button> </button>
@@ -71,7 +71,7 @@ script#template-download(type="text/x-tmpl").
Create Create
</div> </div>
{% } else { %} {% } else { %}
<button class="btn btn-warning cancel"> <button class="btn btn-outline-secondary cancel">
<i class="ion-close-round"></i> <i class="ion-close-round"></i>
<span>Cancel</span> <span>Cancel</span>
</button> </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-body
| ... | ...
.modal-footer .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 button.btn.btn-primary(type='button') Save changes

View File

@@ -29,32 +29,20 @@ html(lang="en")
meta(name="twitter:image", content="") meta(name="twitter:image", content="")
| {% endblock %} | {% 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/jquery.typeahead-0.11.1.min.js')}}")
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/js.cookie-2.0.3.min.js')}}") script(src="{{ url_for('static_pillar', filename='assets/js/vendor/js.cookie-2.0.3.min.js')}}")
| {% if current_user.is_authenticated %} | {% if current_user.is_authenticated %}
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/clipboard.min.js')}}") script(src="{{ url_for('static_pillar', filename='assets/js/vendor/clipboard.min.js')}}")
| {% endif %} | {% 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/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', 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 head %}{% endblock %}
| {% block css %} | {% block css %}
link(href="{{ url_for('static_pillar', filename='assets/css/font-pillar.css') }}", rel="stylesheet") link(href="{{ url_for('static_pillar', filename='assets/css/font-pillar.css') }}", rel="stylesheet")
link(href="{{ url_for('static_pillar', filename='assets/css/base.css') }}", rel="stylesheet")
| {% if title == 'blog' %} | {% if title == 'blog' %}
link(href="{{ url_for('static_pillar', filename='assets/css/blog.css') }}", rel="stylesheet") link(href="{{ url_for('static_pillar', filename='assets/css/blog.css') }}", rel="stylesheet")
| {% else %} | {% else %}
@@ -66,7 +54,6 @@ html(lang="en")
| {% if not title %}{% set title="default" %}{% endif %} | {% if not title %}{% set title="default" %}{% endif %}
body(class="{{ title }}") body(class="{{ title }}")
.container-page
.page-content .page-content
.page-body .page-body
| {% block body %}{% endblock %} | {% block body %}{% endblock %}
@@ -84,8 +71,6 @@ html(lang="en")
| {% endblock footer %} | {% endblock footer %}
| {% endblock footer_container%} | {% 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 %} | {% if current_user.is_authenticated %}
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.typewatch-3.0.0.min.js') }}") script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.typewatch-3.0.0.min.js') }}")
script. script.

View File

@@ -1,7 +1,8 @@
| {% if current_user.is_authenticated %} | {% if current_user.is_authenticated %}
li.nav-notifications li.nav-notifications.nav-item
a.navbar-item#notifications-toggle( a.nav-link.px-2(
id="notifications-toggle",
title="Notifications", title="Notifications",
data-toggle="tooltip", data-toggle="tooltip",
data-placement="bottom") data-placement="bottom")

View File

@@ -1,7 +1,7 @@
| {% block menu_body %} | {% block menu_body %}
| {% if current_user.is_authenticated %} | {% if current_user.is_authenticated %}
li(class="dropdown") li.dropdown
| {% block menu_avatar %} | {% block menu_avatar %}
a.navbar-item.dropdown-toggle(href="#", data-toggle="dropdown", title="{{ current_user.email }}") a.navbar-item.dropdown-toggle(href="#", data-toggle="dropdown", title="{{ current_user.email }}")
img.gravatar( img.gravatar(
@@ -9,42 +9,19 @@ li(class="dropdown")
alt="Avatar") alt="Avatar")
| {% endblock menu_avatar %} | {% endblock menu_avatar %}
ul.dropdown-menu ul.dropdown-menu.dropdown-menu-right
| {% if not current_user.has_role('protected') %} | {% if not current_user.has_role('protected') %}
| {% block menu_list %} | {% block menu_list %}
li
a.navbar-item(
href="{{ url_for('projects.home_project') }}"
title="Home")
i.pi-home
| Home
li li
a.navbar-item( a.navbar-item.px-2(
href="{{ url_for('projects.index') }}"
title="My Projects")
i.pi-star
| My Projects
| {% if current_user.has_organizations() %}
li
a.navbar-item(
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') }}" href="{{ url_for('settings.profile') }}"
title="Settings") title="Settings")
i.pi-cog | #[i.pi-cog] Settings
| Settings
| {% endblock menu_list %} | {% endblock menu_list %}
li.divider(role="separator") li.dropdown-divider(role="separator")
| {% endif %} | {% endif %}
li li
@@ -52,15 +29,16 @@ li(class="dropdown")
href="{{ url_for('users.logout') }}") href="{{ url_for('users.logout') }}")
i.pi-log-out(title="Log Out") i.pi-log-out(title="Log Out")
| Log out | Log out
a.navbar-item.subitem( a.navbar-item.subitem.pt-0(
href="{{ url_for('users.switch') }}") href="{{ url_for('users.switch') }}")
i.pi-blank i.pi-blank
| Not {{ current_user.full_name }}? | Not {{ current_user.full_name }}?
| {% else %} | {% else %}
li.nav-item-sign-in li.pr-1
a.navbar-item(href="{{ url_for('users.login') }}") a.btn.btn-sm.btn-outline-primary.px-3(
| Log in href="{{ url_for('users.login') }}")
| Log In
| {% endif %} | {% endif %}
| {% endblock menu_body %} | {% 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 div
p Available to Blender Cloud subscribers p Available to Blender Cloud subscribers
hr hr.bg-white
| {% if current_user.has_cap('can-renew-subscription') %} | {% if current_user.has_cap('can-renew-subscription') %}
p p
small You have a subscription, it just needs to be renewed. 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 | #[i.pi-heart] Renew Subscription
| {% else %} | {% else %}
p p
small Support Blender and get awesome stuff! 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 | #[i.pi-heart] Get a Subscription
| {% endif %} | {% endif %}
| {% if current_user.is_anonymous %} | {% if current_user.is_anonymous %}
p(style="margin-top: 15px") p(style="margin-top: 15px")
small 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 %} | {% endif %}

View File

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

View File

@@ -23,7 +23,7 @@ section.node-preview.video
| {% block node_download %} | {% block node_download %}
| {% if node.file_variations %} | {% if node.file_variations %}
button.btn.btn-default.dropdown-toggle( button.btn.btn-outline-primary.dropdown-toggle.px-3(
type="button", type="button",
data-toggle="dropdown", data-toggle="dropdown",
aria-haspopup="true", aria-haspopup="true",
@@ -32,7 +32,7 @@ button.btn.btn-default.dropdown-toggle(
| Download | Download
i.pi-angle-down.icon-dropdown-menu i.pi-angle-down.icon-dropdown-menu
ul.dropdown-menu ul.dropdown-menu.dropdown-menu-right
| {% for variation in node.file_variations %} | {% for variation in node.file_variations %}
li li
a(href="{{ variation.link }}", a(href="{{ variation.link }}",
@@ -52,25 +52,49 @@ script(type="text/javascript").
{% if node.video_sources %} {% if node.video_sources %}
var videoPlayer = document.getElementById('videoplayer'); var videoPlayer = document.getElementById('videoplayer');
var options = { var options = {
controlBar: { controlBar: {
volumePanel: { inline: false } 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({ this.ga({
'eventLabel' : '{{ node._id }} - {{ node.name }}', 'eventLabel' : '{{ node._id }} - {{ node.name }}',
'eventCategory' : '{{ node.project }}', 'eventCategory' : '{{ node.project }}',
'eventsToTrack' : ['start', 'error', 'percentsPlayed'] '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);
}
}
}
}); });
videojs(videoPlayer, options).ready(function() { this.rememberVolumePlugin();
this.hotkeys();
{% 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 %}
});
// Generic utility to add-buttons to the player.
function addVideoPlayerButton(data) { function addVideoPlayerButton(data) {
var controlBar, var controlBar,
@@ -89,6 +113,7 @@ script(type="text/javascript").
return newButton; return newButton;
} }
// Video loop stuff. TODO: Move it to video_plugins.js
var videoPlayerLoopButton = addVideoPlayerButton({ var videoPlayerLoopButton = addVideoPlayerButton({
player: videoPlayer, player: videoPlayer,
class: 'vjs-loop-button', class: 'vjs-loop-button',
@@ -96,15 +121,18 @@ script(type="text/javascript").
title: 'Loop' title: 'Loop'
}); });
videoPlayerLoopButton.onclick = function() { function videoPlayerToggleLoop(videoPlayer, videoPlayerLoopButton) {
if (videoPlayer.loop){ if (videoPlayer.loop){
videoPlayer.loop = false; videoPlayer.loop = false;
$(this).removeClass('vjs-control-active'); $(videoPlayerLoopButton).removeClass('vjs-control-active');
} else { } else {
videoPlayer.loop = true; videoPlayer.loop = true;
$(this).addClass('vjs-control-active'); $(videoPlayerLoopButton).addClass('vjs-control-active');
} }
}
videoPlayerLoopButton.onclick = function() {
videoPlayerToggleLoop(videoPlayer, videoPlayerLoopButton);
}; };
{% endif %} // if node.video_sources {% 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