Merge branch 'master' into production
This commit is contained in:
commit
31244a89e5
1
.gitignore
vendored
1
.gitignore
vendored
@ -27,6 +27,7 @@ profile.stats
|
||||
|
||||
pillar/web/static/assets/css/*.css
|
||||
pillar/web/static/assets/js/*.min.js
|
||||
pillar/web/static/assets/js/vendor/video.min.js
|
||||
pillar/web/static/storage/
|
||||
pillar/web/static/uploads/
|
||||
pillar/web/templates/
|
||||
|
60
gulpfile.js
60
gulpfile.js
@ -12,15 +12,16 @@ var pug = require('gulp-pug');
|
||||
var rename = require('gulp-rename');
|
||||
var sass = require('gulp-sass');
|
||||
var sourcemaps = require('gulp-sourcemaps');
|
||||
var uglify = require('gulp-uglify');
|
||||
var uglify = require('gulp-uglify-es').default;
|
||||
|
||||
var enabled = {
|
||||
uglify: argv.production,
|
||||
maps: argv.production,
|
||||
maps: !argv.production,
|
||||
failCheck: !argv.production,
|
||||
prettyPug: !argv.production,
|
||||
cachify: !argv.production,
|
||||
cleanup: argv.production,
|
||||
chmod: argv.production,
|
||||
};
|
||||
|
||||
var destination = {
|
||||
@ -29,6 +30,11 @@ var destination = {
|
||||
js: 'pillar/web/static/assets/js',
|
||||
}
|
||||
|
||||
var source = {
|
||||
bootstrap: 'node_modules/bootstrap/',
|
||||
jquery: 'node_modules/jquery/',
|
||||
popper: 'node_modules/popper.js/'
|
||||
}
|
||||
|
||||
/* CSS */
|
||||
gulp.task('styles', function() {
|
||||
@ -67,36 +73,52 @@ gulp.task('scripts', function() {
|
||||
.pipe(gulpif(enabled.uglify, uglify()))
|
||||
.pipe(rename({suffix: '.min'}))
|
||||
.pipe(gulpif(enabled.maps, sourcemaps.write(".")))
|
||||
.pipe(chmod(644))
|
||||
.pipe(gulpif(enabled.chmod, chmod(644)))
|
||||
.pipe(gulp.dest(destination.js))
|
||||
.pipe(gulpif(argv.livereload, livereload()));
|
||||
});
|
||||
|
||||
|
||||
/* Collection of scripts in src/scripts/tutti/ to merge into tutti.min.js */
|
||||
/* Since it's always loaded, it's only for functions that we want site-wide */
|
||||
/* Collection of scripts in src/scripts/tutti/ to merge into tutti.min.js
|
||||
* Since it's always loaded, it's only for functions that we want site-wide.
|
||||
* It also includes jQuery and Bootstrap (and its dependency popper), since
|
||||
* the site doesn't work without it anyway.*/
|
||||
gulp.task('scripts_concat_tutti', function() {
|
||||
gulp.src('src/scripts/tutti/**/*.js')
|
||||
|
||||
toUglify = [
|
||||
source.jquery + 'dist/jquery.min.js',
|
||||
source.popper + 'dist/umd/popper.min.js',
|
||||
source.bootstrap + 'js/dist/index.js',
|
||||
source.bootstrap + 'js/dist/util.js',
|
||||
source.bootstrap + 'js/dist/alert.js',
|
||||
source.bootstrap + 'js/dist/collapse.js',
|
||||
source.bootstrap + 'js/dist/dropdown.js',
|
||||
source.bootstrap + 'js/dist/tooltip.js',
|
||||
'src/scripts/tutti/**/*.js'
|
||||
];
|
||||
|
||||
gulp.src(toUglify)
|
||||
.pipe(gulpif(enabled.failCheck, plumber()))
|
||||
.pipe(gulpif(enabled.maps, sourcemaps.init()))
|
||||
.pipe(concat("tutti.min.js"))
|
||||
.pipe(gulpif(enabled.uglify, uglify()))
|
||||
.pipe(gulpif(enabled.maps, sourcemaps.write(".")))
|
||||
.pipe(chmod(644))
|
||||
.pipe(gulpif(enabled.chmod, chmod(644)))
|
||||
.pipe(gulp.dest(destination.js))
|
||||
.pipe(gulpif(argv.livereload, livereload()));
|
||||
});
|
||||
|
||||
gulp.task('scripts_concat_markdown', function() {
|
||||
gulp.src('src/scripts/markdown/**/*.js')
|
||||
.pipe(gulpif(enabled.failCheck, plumber()))
|
||||
.pipe(gulpif(enabled.maps, sourcemaps.init()))
|
||||
.pipe(concat("markdown.min.js"))
|
||||
.pipe(gulpif(enabled.uglify, uglify()))
|
||||
.pipe(gulpif(enabled.maps, sourcemaps.write(".")))
|
||||
.pipe(chmod(644))
|
||||
.pipe(gulp.dest(destination.js))
|
||||
.pipe(gulpif(argv.livereload, livereload()));
|
||||
|
||||
/* Simply move these vendor scripts from node_modules. */
|
||||
gulp.task('scripts_move_vendor', function(done) {
|
||||
|
||||
let toMove = [
|
||||
'node_modules/video.js/dist/video.min.js',
|
||||
];
|
||||
|
||||
gulp.src(toMove)
|
||||
.pipe(gulp.dest(destination.js + '/vendor/'));
|
||||
done();
|
||||
});
|
||||
|
||||
|
||||
@ -111,9 +133,9 @@ gulp.task('watch',function() {
|
||||
gulp.watch('src/templates/**/*.pug',['templates']);
|
||||
gulp.watch('src/scripts/*.js',['scripts']);
|
||||
gulp.watch('src/scripts/tutti/**/*.js',['scripts_concat_tutti']);
|
||||
gulp.watch('src/scripts/markdown/**/*.js',['scripts_concat_markdown']);
|
||||
});
|
||||
|
||||
|
||||
// Erases all generated files in output directories.
|
||||
gulp.task('cleanup', function() {
|
||||
var paths = [];
|
||||
@ -136,5 +158,5 @@ gulp.task('default', tasks.concat([
|
||||
'templates',
|
||||
'scripts',
|
||||
'scripts_concat_tutti',
|
||||
'scripts_concat_markdown',
|
||||
'scripts_move_vendor',
|
||||
]));
|
||||
|
3597
package-lock.json
generated
3597
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
54
package.json
54
package.json
@ -1,26 +1,32 @@
|
||||
{
|
||||
"name": "pillar",
|
||||
"license": "GPL-2.0+",
|
||||
"author": "Blender Institute",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/armadillica/pillar.git"
|
||||
},
|
||||
"devDependencies": {
|
||||
"gulp": "~3.9.1",
|
||||
"gulp-autoprefixer": "~2.3.1",
|
||||
"gulp-cached": "~1.1.0",
|
||||
"gulp-chmod": "~1.3.0",
|
||||
"gulp-concat": "~2.6.0",
|
||||
"gulp-if": "^2.0.1",
|
||||
"gulp-git": "~2.4.2",
|
||||
"gulp-livereload": "~3.8.1",
|
||||
"gulp-plumber": "~1.1.0",
|
||||
"gulp-pug": "~3.2.0",
|
||||
"gulp-rename": "~1.2.2",
|
||||
"gulp-sass": "~2.3.1",
|
||||
"gulp-sourcemaps": "~1.6.0",
|
||||
"gulp-uglify": "~1.5.3",
|
||||
"minimist": "^1.2.0"
|
||||
}
|
||||
"name": "pillar",
|
||||
"license": "GPL-2.0+",
|
||||
"author": "Blender Institute",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git://git.blender.org/pillar.git"
|
||||
},
|
||||
"devDependencies": {
|
||||
"gulp": "^3.9.1",
|
||||
"gulp-autoprefixer": "^6.0.0",
|
||||
"gulp-cached": "^1.1.1",
|
||||
"gulp-chmod": "^2.0.0",
|
||||
"gulp-concat": "^2.6.1",
|
||||
"gulp-if": "^2.0.2",
|
||||
"gulp-git": "^2.8.0",
|
||||
"gulp-livereload": "^4.0.0",
|
||||
"gulp-plumber": "^1.2.0",
|
||||
"gulp-pug": "^4.0.1",
|
||||
"gulp-rename": "^1.4.0",
|
||||
"gulp-sass": "^4.0.1",
|
||||
"gulp-sourcemaps": "^2.6.4",
|
||||
"gulp-uglify-es": "^1.0.4",
|
||||
"minimist": "^1.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"bootstrap": "^4.1.3",
|
||||
"jquery": "^3.3.1",
|
||||
"popper.js": "^1.14.4",
|
||||
"video.js": "^7.2.2"
|
||||
}
|
||||
}
|
||||
|
@ -140,8 +140,6 @@ class PillarServer(BlinkerCompatibleEve):
|
||||
|
||||
self.org_manager = pillar.api.organizations.OrgManager()
|
||||
|
||||
self.before_first_request(self.setup_db_indices)
|
||||
|
||||
# Make CSRF protection available to the application. By default it is
|
||||
# disabled on all endpoints. More info at WTF_CSRF_CHECK_DEFAULT in config.py
|
||||
self.csrf = CSRFProtect(self)
|
||||
@ -479,10 +477,11 @@ class PillarServer(BlinkerCompatibleEve):
|
||||
|
||||
# Pillar-defined Celery task modules:
|
||||
celery_task_modules = [
|
||||
'pillar.celery.tasks',
|
||||
'pillar.celery.search_index_tasks',
|
||||
'pillar.celery.file_link_tasks',
|
||||
'pillar.celery.badges',
|
||||
'pillar.celery.email_tasks',
|
||||
'pillar.celery.file_link_tasks',
|
||||
'pillar.celery.search_index_tasks',
|
||||
'pillar.celery.tasks',
|
||||
]
|
||||
|
||||
# Allow Pillar extensions from defining their own Celery tasks.
|
||||
@ -704,6 +703,8 @@ class PillarServer(BlinkerCompatibleEve):
|
||||
def finish_startup(self):
|
||||
self.log.info('Using MongoDB database %r', self.config['MONGO_DBNAME'])
|
||||
|
||||
with self.app_context():
|
||||
self.setup_db_indices()
|
||||
self._config_celery()
|
||||
|
||||
api.setup_app(self)
|
||||
@ -760,6 +761,8 @@ class PillarServer(BlinkerCompatibleEve):
|
||||
coll.create_index([('properties.status', pymongo.ASCENDING),
|
||||
('node_type', pymongo.ASCENDING),
|
||||
('_created', pymongo.DESCENDING)])
|
||||
# Used for asset tags
|
||||
coll.create_index([('properties.tags', pymongo.ASCENDING)])
|
||||
|
||||
coll = db['projects']
|
||||
# This index is used for statistics, and for fetching public projects.
|
||||
|
@ -220,7 +220,7 @@ def fetch_blenderid_user() -> dict:
|
||||
|
||||
my_log = log.getChild('fetch_blenderid_user')
|
||||
|
||||
bid_url = '%s/api/user' % current_app.config['BLENDER_ID_ENDPOINT']
|
||||
bid_url = urljoin(current_app.config['BLENDER_ID_ENDPOINT'], 'api/user')
|
||||
my_log.debug('Fetching user info from %s', bid_url)
|
||||
|
||||
credentials = current_app.config['OAUTH_CREDENTIALS']['blender-id']
|
||||
|
@ -123,6 +123,43 @@ users_schema = {
|
||||
'allow_unknown': True,
|
||||
},
|
||||
|
||||
# Node-specific information for this user.
|
||||
'nodes': {
|
||||
'type': 'dict',
|
||||
'schema': {
|
||||
# Per watched video info about where the user left off, both in time and in percent.
|
||||
'view_progress': {
|
||||
'type': 'dict',
|
||||
# Keyed by Node ID of the video asset. MongoDB doesn't support using
|
||||
# ObjectIds as key, so we cast them to string instead.
|
||||
'keyschema': {'type': 'string'},
|
||||
'valueschema': {
|
||||
'type': 'dict',
|
||||
'schema': {
|
||||
'progress_in_sec': {'type': 'float', 'min': 0},
|
||||
'progress_in_percent': {'type': 'integer', 'min': 0, 'max': 100},
|
||||
|
||||
# When the progress was last updated, so we can limit this history to
|
||||
# the last-watched N videos if we want, or show stuff in chrono order.
|
||||
'last_watched': {'type': 'datetime'},
|
||||
|
||||
# True means progress_in_percent = 100, for easy querying
|
||||
'done': {'type': 'boolean', 'default': False},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
},
|
||||
},
|
||||
|
||||
'badges': {
|
||||
'type': 'dict',
|
||||
'schema': {
|
||||
'html': {'type': 'string'}, # HTML fetched from Blender ID.
|
||||
'expires': {'type': 'datetime'}, # When we should fetch it again.
|
||||
},
|
||||
},
|
||||
|
||||
# Properties defined by extensions. Extensions should use their name (see the
|
||||
# PillarExtension.name property) as the key, and are free to use whatever they want as value,
|
||||
# but we suggest a dict for future extendability.
|
||||
@ -339,11 +376,11 @@ tokens_schema = {
|
||||
},
|
||||
'token': {
|
||||
'type': 'string',
|
||||
'required': False,
|
||||
'required': True,
|
||||
},
|
||||
'token_hashed': {
|
||||
'type': 'string',
|
||||
'required': True,
|
||||
'required': False,
|
||||
},
|
||||
'expire_time': {
|
||||
'type': 'datetime',
|
||||
@ -362,6 +399,13 @@ tokens_schema = {
|
||||
'type': 'string',
|
||||
},
|
||||
},
|
||||
|
||||
# OAuth scopes granted to this token.
|
||||
'oauth_scopes': {
|
||||
'type': 'list',
|
||||
'default': [],
|
||||
'schema': {'type': 'string'},
|
||||
}
|
||||
}
|
||||
|
||||
files_schema = {
|
||||
|
@ -94,17 +94,10 @@ def generate_and_store_token(user_id, days=15, prefix=b'') -> dict:
|
||||
|
||||
# Use 'xy' as altargs to prevent + and / characters from appearing.
|
||||
# We never have to b64decode the string anyway.
|
||||
token_bytes = prefix + base64.b64encode(random_bits, altchars=b'xy').strip(b'=')
|
||||
token = token_bytes.decode('ascii')
|
||||
token = prefix + base64.b64encode(random_bits, altchars=b'xy').strip(b'=')
|
||||
|
||||
token_expiry = utcnow() + datetime.timedelta(days=days)
|
||||
token_data = store_token(user_id, token, token_expiry)
|
||||
|
||||
# Include the token in the returned document so that it can be stored client-side,
|
||||
# in configuration, etc.
|
||||
token_data['token'] = token
|
||||
|
||||
return token_data
|
||||
return store_token(user_id, token.decode('ascii'), token_expiry)
|
||||
|
||||
|
||||
def hash_password(password: str, salt: typing.Union[str, bytes]) -> str:
|
||||
|
@ -1,17 +1,11 @@
|
||||
import base64
|
||||
import functools
|
||||
import logging
|
||||
import urllib.parse
|
||||
|
||||
import pymongo.errors
|
||||
import werkzeug.exceptions as wz_exceptions
|
||||
from bson import ObjectId
|
||||
from flask import current_app, Blueprint, request
|
||||
|
||||
import pillar.markdown
|
||||
from pillar.api.activities import activity_subscribe, activity_object_add
|
||||
from pillar.api.node_types import PILLAR_NAMED_NODE_TYPES
|
||||
from pillar.api.file_storage_backends.gcs import update_file_name
|
||||
from pillar.api.nodes import eve_hooks
|
||||
from pillar.api.utils import str2id, jsonify
|
||||
from pillar.api.utils.authorization import check_permissions, require_login
|
||||
|
||||
@ -20,40 +14,6 @@ blueprint = Blueprint('nodes_api', __name__)
|
||||
ROLES_FOR_SHARING = {'subscriber', 'demo'}
|
||||
|
||||
|
||||
def only_for_node_type_decorator(*required_node_type_names):
|
||||
"""Returns a decorator that checks its first argument's node type.
|
||||
|
||||
If the node type is not of the required node type, returns None,
|
||||
otherwise calls the wrapped function.
|
||||
|
||||
>>> deco = only_for_node_type_decorator('comment')
|
||||
>>> @deco
|
||||
... def handle_comment(node): pass
|
||||
|
||||
>>> deco = only_for_node_type_decorator('comment', 'post')
|
||||
>>> @deco
|
||||
... def handle_comment_or_post(node): pass
|
||||
|
||||
"""
|
||||
|
||||
# Convert to a set for efficient 'x in required_node_type_names' queries.
|
||||
required_node_type_names = set(required_node_type_names)
|
||||
|
||||
def only_for_node_type(wrapped):
|
||||
@functools.wraps(wrapped)
|
||||
def wrapper(node, *args, **kwargs):
|
||||
if node.get('node_type') not in required_node_type_names:
|
||||
return
|
||||
|
||||
return wrapped(node, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
only_for_node_type.__doc__ = "Decorator, immediately returns when " \
|
||||
"the first argument is not of type %s." % required_node_type_names
|
||||
return only_for_node_type
|
||||
|
||||
|
||||
@blueprint.route('/<node_id>/share', methods=['GET', 'POST'])
|
||||
@require_login(require_roles=ROLES_FOR_SHARING)
|
||||
def share_node(node_id):
|
||||
@ -86,7 +46,68 @@ def share_node(node_id):
|
||||
else:
|
||||
return '', 204
|
||||
|
||||
return jsonify(short_link_info(short_code), status=status)
|
||||
return jsonify(eve_hooks.short_link_info(short_code), status=status)
|
||||
|
||||
|
||||
@blueprint.route('/tagged/')
|
||||
@blueprint.route('/tagged/<tag>')
|
||||
def tagged(tag=''):
|
||||
"""Return all tagged nodes of public projects as JSON."""
|
||||
from pillar.auth import current_user
|
||||
|
||||
# We explicitly register the tagless endpoint to raise a 404, otherwise the PATCH
|
||||
# handler on /api/nodes/<node_id> will return a 405 Method Not Allowed.
|
||||
if not tag:
|
||||
raise wz_exceptions.NotFound()
|
||||
|
||||
# Build the (cached) list of tagged nodes
|
||||
agg_list = _tagged(tag)
|
||||
|
||||
# If the user is anonymous, no more information is needed and we return
|
||||
if current_user.is_anonymous:
|
||||
return jsonify(agg_list)
|
||||
|
||||
# If the user is authenticated, attach view_progress for video assets
|
||||
view_progress = current_user.nodes['view_progress']
|
||||
for node in agg_list:
|
||||
node_id = str(node['_id'])
|
||||
# View progress should be added only for nodes of type 'asset' and
|
||||
# with content_type 'video', only if the video was already in the watched
|
||||
# list for the current user.
|
||||
if node_id in view_progress:
|
||||
node['view_progress'] = view_progress[node_id]
|
||||
|
||||
return jsonify(agg_list)
|
||||
|
||||
|
||||
def _tagged(tag: str):
|
||||
"""Fetch all public nodes with the given tag.
|
||||
|
||||
This function is cached, see setup_app().
|
||||
"""
|
||||
nodes_coll = current_app.db('nodes')
|
||||
agg = nodes_coll.aggregate([
|
||||
{'$match': {'properties.tags': tag,
|
||||
'_deleted': {'$ne': True}}},
|
||||
|
||||
# Only get nodes from public projects. This is done after matching the
|
||||
# tagged nodes, because most likely nobody else will be able to tag
|
||||
# nodes anyway.
|
||||
{'$lookup': {
|
||||
'from': 'projects',
|
||||
'localField': 'project',
|
||||
'foreignField': '_id',
|
||||
'as': '_project',
|
||||
}},
|
||||
{'$match': {'_project.is_private': False}},
|
||||
|
||||
# Don't return the entire project for each node.
|
||||
{'$project': {'_project': False}},
|
||||
|
||||
{'$sort': {'_created': -1}}
|
||||
])
|
||||
|
||||
return list(agg)
|
||||
|
||||
|
||||
def generate_and_store_short_code(node):
|
||||
@ -164,307 +185,35 @@ def create_short_code(node) -> str:
|
||||
return short_code
|
||||
|
||||
|
||||
def short_link_info(short_code):
|
||||
"""Returns the short link info in a dict."""
|
||||
|
||||
short_link = urllib.parse.urljoin(
|
||||
current_app.config['SHORT_LINK_BASE_URL'], short_code)
|
||||
|
||||
return {
|
||||
'short_code': short_code,
|
||||
'short_link': short_link,
|
||||
}
|
||||
|
||||
|
||||
def before_replacing_node(item, original):
|
||||
check_permissions('nodes', original, 'PUT')
|
||||
update_file_name(item)
|
||||
|
||||
|
||||
def after_replacing_node(item, original):
|
||||
"""Push an update to the Algolia index when a node item is updated. If the
|
||||
project is private, prevent public indexing.
|
||||
"""
|
||||
|
||||
from pillar.celery import search_index_tasks as index
|
||||
|
||||
projects_collection = current_app.data.driver.db['projects']
|
||||
project = projects_collection.find_one({'_id': item['project']})
|
||||
if project.get('is_private', False):
|
||||
# Skip index updating and return
|
||||
return
|
||||
|
||||
status = item['properties'].get('status', 'unpublished')
|
||||
node_id = str(item['_id'])
|
||||
|
||||
if status == 'published':
|
||||
index.node_save.delay(node_id)
|
||||
else:
|
||||
index.node_delete.delay(node_id)
|
||||
|
||||
|
||||
def before_inserting_nodes(items):
|
||||
"""Before inserting a node in the collection we check if the user is allowed
|
||||
and we append the project id to it.
|
||||
"""
|
||||
from pillar.auth import current_user
|
||||
|
||||
nodes_collection = current_app.data.driver.db['nodes']
|
||||
|
||||
def find_parent_project(node):
|
||||
"""Recursive function that finds the ultimate parent of a node."""
|
||||
if node and 'parent' in node:
|
||||
parent = nodes_collection.find_one({'_id': node['parent']})
|
||||
return find_parent_project(parent)
|
||||
if node:
|
||||
return node
|
||||
else:
|
||||
return None
|
||||
|
||||
for item in items:
|
||||
check_permissions('nodes', item, 'POST')
|
||||
if 'parent' in item and 'project' not in item:
|
||||
parent = nodes_collection.find_one({'_id': item['parent']})
|
||||
project = find_parent_project(parent)
|
||||
if project:
|
||||
item['project'] = project['_id']
|
||||
|
||||
# Default the 'user' property to the current user.
|
||||
item.setdefault('user', current_user.user_id)
|
||||
|
||||
|
||||
def after_inserting_nodes(items):
|
||||
for item in items:
|
||||
# Skip subscriptions for first level items (since the context is not a
|
||||
# node, but a project).
|
||||
# TODO: support should be added for mixed context
|
||||
if 'parent' not in item:
|
||||
return
|
||||
context_object_id = item['parent']
|
||||
if item['node_type'] == 'comment':
|
||||
nodes_collection = current_app.data.driver.db['nodes']
|
||||
parent = nodes_collection.find_one({'_id': item['parent']})
|
||||
# Always subscribe to the parent node
|
||||
activity_subscribe(item['user'], 'node', item['parent'])
|
||||
if parent['node_type'] == 'comment':
|
||||
# If the parent is a comment, we provide its own parent as
|
||||
# context. We do this in order to point the user to an asset
|
||||
# or group when viewing the notification.
|
||||
verb = 'replied'
|
||||
context_object_id = parent['parent']
|
||||
# Subscribe to the parent of the parent comment (post or group)
|
||||
activity_subscribe(item['user'], 'node', parent['parent'])
|
||||
else:
|
||||
activity_subscribe(item['user'], 'node', item['_id'])
|
||||
verb = 'commented'
|
||||
elif item['node_type'] in PILLAR_NAMED_NODE_TYPES:
|
||||
verb = 'posted'
|
||||
activity_subscribe(item['user'], 'node', item['_id'])
|
||||
else:
|
||||
# Don't automatically create activities for non-Pillar node types,
|
||||
# as we don't know what would be a suitable verb (among other things).
|
||||
continue
|
||||
|
||||
activity_object_add(
|
||||
item['user'],
|
||||
verb,
|
||||
'node',
|
||||
item['_id'],
|
||||
'node',
|
||||
context_object_id
|
||||
)
|
||||
|
||||
|
||||
def deduct_content_type(node_doc, original=None):
|
||||
"""Deduct the content type from the attached file, if any."""
|
||||
|
||||
if node_doc['node_type'] != 'asset':
|
||||
log.debug('deduct_content_type: called on node type %r, ignoring', node_doc['node_type'])
|
||||
return
|
||||
|
||||
node_id = node_doc.get('_id')
|
||||
try:
|
||||
file_id = ObjectId(node_doc['properties']['file'])
|
||||
except KeyError:
|
||||
if node_id is None:
|
||||
# Creation of a file-less node is allowed, but updates aren't.
|
||||
return
|
||||
log.warning('deduct_content_type: Asset without properties.file, rejecting.')
|
||||
raise wz_exceptions.UnprocessableEntity('Missing file property for asset node')
|
||||
|
||||
files = current_app.data.driver.db['files']
|
||||
file_doc = files.find_one({'_id': file_id},
|
||||
{'content_type': 1})
|
||||
if not file_doc:
|
||||
log.warning('deduct_content_type: Node %s refers to non-existing file %s, rejecting.',
|
||||
node_id, file_id)
|
||||
raise wz_exceptions.UnprocessableEntity('File property refers to non-existing file')
|
||||
|
||||
# Guess the node content type from the file content type
|
||||
file_type = file_doc['content_type']
|
||||
if file_type.startswith('video/'):
|
||||
content_type = 'video'
|
||||
elif file_type.startswith('image/'):
|
||||
content_type = 'image'
|
||||
else:
|
||||
content_type = 'file'
|
||||
|
||||
node_doc['properties']['content_type'] = content_type
|
||||
|
||||
|
||||
def nodes_deduct_content_type(nodes):
|
||||
for node in nodes:
|
||||
deduct_content_type(node)
|
||||
|
||||
|
||||
def before_returning_node(node):
|
||||
# Run validation process, since GET on nodes entry point is public
|
||||
check_permissions('nodes', node, 'GET', append_allowed_methods=True)
|
||||
|
||||
# Embed short_link_info if the node has a short_code.
|
||||
short_code = node.get('short_code')
|
||||
if short_code:
|
||||
node['short_link'] = short_link_info(short_code)['short_link']
|
||||
|
||||
|
||||
def before_returning_nodes(nodes):
|
||||
for node in nodes['_items']:
|
||||
before_returning_node(node)
|
||||
|
||||
|
||||
def node_set_default_picture(node, original=None):
|
||||
"""Uses the image of an image asset or colour map of texture node as picture."""
|
||||
|
||||
if node.get('picture'):
|
||||
log.debug('Node %s already has a picture, not overriding', node.get('_id'))
|
||||
return
|
||||
|
||||
node_type = node.get('node_type')
|
||||
props = node.get('properties', {})
|
||||
content = props.get('content_type')
|
||||
|
||||
if node_type == 'asset' and content == 'image':
|
||||
image_file_id = props.get('file')
|
||||
elif node_type == 'texture':
|
||||
# Find the colour map, defaulting to the first image map available.
|
||||
image_file_id = None
|
||||
for image in props.get('files', []):
|
||||
if image_file_id is None or image.get('map_type') == 'color':
|
||||
image_file_id = image.get('file')
|
||||
else:
|
||||
log.debug('Not setting default picture on node type %s content type %s',
|
||||
node_type, content)
|
||||
return
|
||||
|
||||
if image_file_id is None:
|
||||
log.debug('Nothing to set the picture to.')
|
||||
return
|
||||
|
||||
log.debug('Setting default picture for node %s to %s', node.get('_id'), image_file_id)
|
||||
node['picture'] = image_file_id
|
||||
|
||||
|
||||
def nodes_set_default_picture(nodes):
|
||||
for node in nodes:
|
||||
node_set_default_picture(node)
|
||||
|
||||
|
||||
def before_deleting_node(node: dict):
|
||||
check_permissions('nodes', node, 'DELETE')
|
||||
|
||||
|
||||
def after_deleting_node(item):
|
||||
from pillar.celery import search_index_tasks as index
|
||||
index.node_delete.delay(str(item['_id']))
|
||||
|
||||
|
||||
only_for_textures = only_for_node_type_decorator('texture')
|
||||
|
||||
|
||||
@only_for_textures
|
||||
def texture_sort_files(node, original=None):
|
||||
"""Sort files alphabetically by map type, with colour map first."""
|
||||
|
||||
try:
|
||||
files = node['properties']['files']
|
||||
except KeyError:
|
||||
return
|
||||
|
||||
# Sort the map types alphabetically, ensuring 'color' comes first.
|
||||
as_dict = {f['map_type']: f for f in files}
|
||||
types = sorted(as_dict.keys(), key=lambda k: '\0' if k == 'color' else k)
|
||||
node['properties']['files'] = [as_dict[map_type] for map_type in types]
|
||||
|
||||
|
||||
def textures_sort_files(nodes):
|
||||
for node in nodes:
|
||||
texture_sort_files(node)
|
||||
|
||||
|
||||
def parse_markdown(node, original=None):
|
||||
import copy
|
||||
|
||||
projects_collection = current_app.data.driver.db['projects']
|
||||
project = projects_collection.find_one({'_id': node['project']}, {'node_types': 1})
|
||||
# Query node type directly using the key
|
||||
node_type = next(nt for nt in project['node_types']
|
||||
if nt['name'] == node['node_type'])
|
||||
|
||||
# Create a copy to not overwrite the actual schema.
|
||||
schema = copy.deepcopy(current_app.config['DOMAIN']['nodes']['schema'])
|
||||
schema['properties'] = node_type['dyn_schema']
|
||||
|
||||
def find_markdown_fields(schema, node):
|
||||
"""Find and process all makrdown validated fields."""
|
||||
for k, v in schema.items():
|
||||
if not isinstance(v, dict):
|
||||
continue
|
||||
|
||||
if v.get('validator') == 'markdown':
|
||||
# If there is a match with the validator: markdown pair, assign the sibling
|
||||
# property (following the naming convention _<property>_html)
|
||||
# the processed value.
|
||||
if k in node:
|
||||
html = pillar.markdown.markdown(node[k])
|
||||
field_name = pillar.markdown.cache_field_name(k)
|
||||
node[field_name] = html
|
||||
if isinstance(node, dict) and k in node:
|
||||
find_markdown_fields(v, node[k])
|
||||
|
||||
find_markdown_fields(schema, node)
|
||||
|
||||
return 'ok'
|
||||
|
||||
|
||||
def parse_markdowns(items):
|
||||
for item in items:
|
||||
parse_markdown(item)
|
||||
|
||||
|
||||
def setup_app(app, url_prefix):
|
||||
global _tagged
|
||||
|
||||
cached = app.cache.memoize(timeout=300)
|
||||
_tagged = cached(_tagged)
|
||||
|
||||
from . import patch
|
||||
patch.setup_app(app, url_prefix=url_prefix)
|
||||
|
||||
app.on_fetched_item_nodes += before_returning_node
|
||||
app.on_fetched_resource_nodes += before_returning_nodes
|
||||
app.on_fetched_item_nodes += eve_hooks.before_returning_node
|
||||
app.on_fetched_resource_nodes += eve_hooks.before_returning_nodes
|
||||
|
||||
app.on_replace_nodes += before_replacing_node
|
||||
app.on_replace_nodes += parse_markdown
|
||||
app.on_replace_nodes += texture_sort_files
|
||||
app.on_replace_nodes += deduct_content_type
|
||||
app.on_replace_nodes += node_set_default_picture
|
||||
app.on_replaced_nodes += after_replacing_node
|
||||
app.on_replace_nodes += eve_hooks.before_replacing_node
|
||||
app.on_replace_nodes += eve_hooks.parse_markdown
|
||||
app.on_replace_nodes += eve_hooks.texture_sort_files
|
||||
app.on_replace_nodes += eve_hooks.deduct_content_type
|
||||
app.on_replace_nodes += eve_hooks.node_set_default_picture
|
||||
app.on_replaced_nodes += eve_hooks.after_replacing_node
|
||||
|
||||
app.on_insert_nodes += before_inserting_nodes
|
||||
app.on_insert_nodes += parse_markdowns
|
||||
app.on_insert_nodes += nodes_deduct_content_type
|
||||
app.on_insert_nodes += nodes_set_default_picture
|
||||
app.on_insert_nodes += textures_sort_files
|
||||
app.on_inserted_nodes += after_inserting_nodes
|
||||
app.on_insert_nodes += eve_hooks.before_inserting_nodes
|
||||
app.on_insert_nodes += eve_hooks.parse_markdowns
|
||||
app.on_insert_nodes += eve_hooks.nodes_deduct_content_type
|
||||
app.on_insert_nodes += eve_hooks.nodes_set_default_picture
|
||||
app.on_insert_nodes += eve_hooks.textures_sort_files
|
||||
app.on_inserted_nodes += eve_hooks.after_inserting_nodes
|
||||
|
||||
app.on_update_nodes += texture_sort_files
|
||||
app.on_update_nodes += eve_hooks.texture_sort_files
|
||||
|
||||
app.on_delete_item_nodes += before_deleting_node
|
||||
app.on_deleted_item_nodes += after_deleting_node
|
||||
app.on_delete_item_nodes += eve_hooks.before_deleting_node
|
||||
app.on_deleted_item_nodes += eve_hooks.after_deleting_node
|
||||
|
||||
app.register_api_blueprint(blueprint, url_prefix=url_prefix)
|
||||
|
325
pillar/api/nodes/eve_hooks.py
Normal file
325
pillar/api/nodes/eve_hooks.py
Normal file
@ -0,0 +1,325 @@
|
||||
import functools
|
||||
import logging
|
||||
import urllib.parse
|
||||
from bson import ObjectId
|
||||
from flask import current_app
|
||||
from werkzeug import exceptions as wz_exceptions
|
||||
|
||||
import pillar.markdown
|
||||
from pillar.api.activities import activity_subscribe, activity_object_add
|
||||
from pillar.api.file_storage_backends.gcs import update_file_name
|
||||
from pillar.api.node_types import PILLAR_NAMED_NODE_TYPES
|
||||
from pillar.api.utils.authorization import check_permissions
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def before_returning_node(node):
|
||||
# Run validation process, since GET on nodes entry point is public
|
||||
check_permissions('nodes', node, 'GET', append_allowed_methods=True)
|
||||
|
||||
# Embed short_link_info if the node has a short_code.
|
||||
short_code = node.get('short_code')
|
||||
if short_code:
|
||||
node['short_link'] = short_link_info(short_code)['short_link']
|
||||
|
||||
|
||||
def before_returning_nodes(nodes):
|
||||
for node in nodes['_items']:
|
||||
before_returning_node(node)
|
||||
|
||||
|
||||
def only_for_node_type_decorator(*required_node_type_names):
|
||||
"""Returns a decorator that checks its first argument's node type.
|
||||
|
||||
If the node type is not of the required node type, returns None,
|
||||
otherwise calls the wrapped function.
|
||||
|
||||
>>> deco = only_for_node_type_decorator('comment')
|
||||
>>> @deco
|
||||
... def handle_comment(node): pass
|
||||
|
||||
>>> deco = only_for_node_type_decorator('comment', 'post')
|
||||
>>> @deco
|
||||
... def handle_comment_or_post(node): pass
|
||||
|
||||
"""
|
||||
|
||||
# Convert to a set for efficient 'x in required_node_type_names' queries.
|
||||
required_node_type_names = set(required_node_type_names)
|
||||
|
||||
def only_for_node_type(wrapped):
|
||||
@functools.wraps(wrapped)
|
||||
def wrapper(node, *args, **kwargs):
|
||||
if node.get('node_type') not in required_node_type_names:
|
||||
return
|
||||
|
||||
return wrapped(node, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
only_for_node_type.__doc__ = "Decorator, immediately returns when " \
|
||||
"the first argument is not of type %s." % required_node_type_names
|
||||
return only_for_node_type
|
||||
|
||||
|
||||
def before_replacing_node(item, original):
|
||||
check_permissions('nodes', original, 'PUT')
|
||||
update_file_name(item)
|
||||
|
||||
|
||||
def after_replacing_node(item, original):
|
||||
"""Push an update to the Algolia index when a node item is updated. If the
|
||||
project is private, prevent public indexing.
|
||||
"""
|
||||
|
||||
from pillar.celery import search_index_tasks as index
|
||||
|
||||
projects_collection = current_app.data.driver.db['projects']
|
||||
project = projects_collection.find_one({'_id': item['project']})
|
||||
if project.get('is_private', False):
|
||||
# Skip index updating and return
|
||||
return
|
||||
|
||||
status = item['properties'].get('status', 'unpublished')
|
||||
node_id = str(item['_id'])
|
||||
|
||||
if status == 'published':
|
||||
index.node_save.delay(node_id)
|
||||
else:
|
||||
index.node_delete.delay(node_id)
|
||||
|
||||
|
||||
def before_inserting_nodes(items):
|
||||
"""Before inserting a node in the collection we check if the user is allowed
|
||||
and we append the project id to it.
|
||||
"""
|
||||
from pillar.auth import current_user
|
||||
|
||||
nodes_collection = current_app.data.driver.db['nodes']
|
||||
|
||||
def find_parent_project(node):
|
||||
"""Recursive function that finds the ultimate parent of a node."""
|
||||
if node and 'parent' in node:
|
||||
parent = nodes_collection.find_one({'_id': node['parent']})
|
||||
return find_parent_project(parent)
|
||||
if node:
|
||||
return node
|
||||
else:
|
||||
return None
|
||||
|
||||
for item in items:
|
||||
check_permissions('nodes', item, 'POST')
|
||||
if 'parent' in item and 'project' not in item:
|
||||
parent = nodes_collection.find_one({'_id': item['parent']})
|
||||
project = find_parent_project(parent)
|
||||
if project:
|
||||
item['project'] = project['_id']
|
||||
|
||||
# Default the 'user' property to the current user.
|
||||
item.setdefault('user', current_user.user_id)
|
||||
|
||||
|
||||
def after_inserting_nodes(items):
|
||||
for item in items:
|
||||
# Skip subscriptions for first level items (since the context is not a
|
||||
# node, but a project).
|
||||
# TODO: support should be added for mixed context
|
||||
if 'parent' not in item:
|
||||
return
|
||||
context_object_id = item['parent']
|
||||
if item['node_type'] == 'comment':
|
||||
nodes_collection = current_app.data.driver.db['nodes']
|
||||
parent = nodes_collection.find_one({'_id': item['parent']})
|
||||
# Always subscribe to the parent node
|
||||
activity_subscribe(item['user'], 'node', item['parent'])
|
||||
if parent['node_type'] == 'comment':
|
||||
# If the parent is a comment, we provide its own parent as
|
||||
# context. We do this in order to point the user to an asset
|
||||
# or group when viewing the notification.
|
||||
verb = 'replied'
|
||||
context_object_id = parent['parent']
|
||||
# Subscribe to the parent of the parent comment (post or group)
|
||||
activity_subscribe(item['user'], 'node', parent['parent'])
|
||||
else:
|
||||
activity_subscribe(item['user'], 'node', item['_id'])
|
||||
verb = 'commented'
|
||||
elif item['node_type'] in PILLAR_NAMED_NODE_TYPES:
|
||||
verb = 'posted'
|
||||
activity_subscribe(item['user'], 'node', item['_id'])
|
||||
else:
|
||||
# Don't automatically create activities for non-Pillar node types,
|
||||
# as we don't know what would be a suitable verb (among other things).
|
||||
continue
|
||||
|
||||
activity_object_add(
|
||||
item['user'],
|
||||
verb,
|
||||
'node',
|
||||
item['_id'],
|
||||
'node',
|
||||
context_object_id
|
||||
)
|
||||
|
||||
|
||||
def deduct_content_type(node_doc, original=None):
|
||||
"""Deduct the content type from the attached file, if any."""
|
||||
|
||||
if node_doc['node_type'] != 'asset':
|
||||
log.debug('deduct_content_type: called on node type %r, ignoring', node_doc['node_type'])
|
||||
return
|
||||
|
||||
node_id = node_doc.get('_id')
|
||||
try:
|
||||
file_id = ObjectId(node_doc['properties']['file'])
|
||||
except KeyError:
|
||||
if node_id is None:
|
||||
# Creation of a file-less node is allowed, but updates aren't.
|
||||
return
|
||||
log.warning('deduct_content_type: Asset without properties.file, rejecting.')
|
||||
raise wz_exceptions.UnprocessableEntity('Missing file property for asset node')
|
||||
|
||||
files = current_app.data.driver.db['files']
|
||||
file_doc = files.find_one({'_id': file_id},
|
||||
{'content_type': 1})
|
||||
if not file_doc:
|
||||
log.warning('deduct_content_type: Node %s refers to non-existing file %s, rejecting.',
|
||||
node_id, file_id)
|
||||
raise wz_exceptions.UnprocessableEntity('File property refers to non-existing file')
|
||||
|
||||
# Guess the node content type from the file content type
|
||||
file_type = file_doc['content_type']
|
||||
if file_type.startswith('video/'):
|
||||
content_type = 'video'
|
||||
elif file_type.startswith('image/'):
|
||||
content_type = 'image'
|
||||
else:
|
||||
content_type = 'file'
|
||||
|
||||
node_doc['properties']['content_type'] = content_type
|
||||
|
||||
|
||||
def nodes_deduct_content_type(nodes):
|
||||
for node in nodes:
|
||||
deduct_content_type(node)
|
||||
|
||||
|
||||
def node_set_default_picture(node, original=None):
|
||||
"""Uses the image of an image asset or colour map of texture node as picture."""
|
||||
|
||||
if node.get('picture'):
|
||||
log.debug('Node %s already has a picture, not overriding', node.get('_id'))
|
||||
return
|
||||
|
||||
node_type = node.get('node_type')
|
||||
props = node.get('properties', {})
|
||||
content = props.get('content_type')
|
||||
|
||||
if node_type == 'asset' and content == 'image':
|
||||
image_file_id = props.get('file')
|
||||
elif node_type == 'texture':
|
||||
# Find the colour map, defaulting to the first image map available.
|
||||
image_file_id = None
|
||||
for image in props.get('files', []):
|
||||
if image_file_id is None or image.get('map_type') == 'color':
|
||||
image_file_id = image.get('file')
|
||||
else:
|
||||
log.debug('Not setting default picture on node type %s content type %s',
|
||||
node_type, content)
|
||||
return
|
||||
|
||||
if image_file_id is None:
|
||||
log.debug('Nothing to set the picture to.')
|
||||
return
|
||||
|
||||
log.debug('Setting default picture for node %s to %s', node.get('_id'), image_file_id)
|
||||
node['picture'] = image_file_id
|
||||
|
||||
|
||||
def nodes_set_default_picture(nodes):
|
||||
for node in nodes:
|
||||
node_set_default_picture(node)
|
||||
|
||||
|
||||
def before_deleting_node(node: dict):
|
||||
check_permissions('nodes', node, 'DELETE')
|
||||
|
||||
|
||||
def after_deleting_node(item):
|
||||
from pillar.celery import search_index_tasks as index
|
||||
index.node_delete.delay(str(item['_id']))
|
||||
|
||||
|
||||
only_for_textures = only_for_node_type_decorator('texture')
|
||||
|
||||
|
||||
@only_for_textures
|
||||
def texture_sort_files(node, original=None):
|
||||
"""Sort files alphabetically by map type, with colour map first."""
|
||||
|
||||
try:
|
||||
files = node['properties']['files']
|
||||
except KeyError:
|
||||
return
|
||||
|
||||
# Sort the map types alphabetically, ensuring 'color' comes first.
|
||||
as_dict = {f['map_type']: f for f in files}
|
||||
types = sorted(as_dict.keys(), key=lambda k: '\0' if k == 'color' else k)
|
||||
node['properties']['files'] = [as_dict[map_type] for map_type in types]
|
||||
|
||||
|
||||
def textures_sort_files(nodes):
|
||||
for node in nodes:
|
||||
texture_sort_files(node)
|
||||
|
||||
|
||||
def parse_markdown(node, original=None):
|
||||
import copy
|
||||
|
||||
projects_collection = current_app.data.driver.db['projects']
|
||||
project = projects_collection.find_one({'_id': node['project']}, {'node_types': 1})
|
||||
# Query node type directly using the key
|
||||
node_type = next(nt for nt in project['node_types']
|
||||
if nt['name'] == node['node_type'])
|
||||
|
||||
# Create a copy to not overwrite the actual schema.
|
||||
schema = copy.deepcopy(current_app.config['DOMAIN']['nodes']['schema'])
|
||||
schema['properties'] = node_type['dyn_schema']
|
||||
|
||||
def find_markdown_fields(schema, node):
|
||||
"""Find and process all makrdown validated fields."""
|
||||
for k, v in schema.items():
|
||||
if not isinstance(v, dict):
|
||||
continue
|
||||
|
||||
if v.get('validator') == 'markdown':
|
||||
# If there is a match with the validator: markdown pair, assign the sibling
|
||||
# property (following the naming convention _<property>_html)
|
||||
# the processed value.
|
||||
if k in node:
|
||||
html = pillar.markdown.markdown(node[k])
|
||||
field_name = pillar.markdown.cache_field_name(k)
|
||||
node[field_name] = html
|
||||
if isinstance(node, dict) and k in node:
|
||||
find_markdown_fields(v, node[k])
|
||||
|
||||
find_markdown_fields(schema, node)
|
||||
|
||||
return 'ok'
|
||||
|
||||
|
||||
def parse_markdowns(items):
|
||||
for item in items:
|
||||
parse_markdown(item)
|
||||
|
||||
|
||||
def short_link_info(short_code):
|
||||
"""Returns the short link info in a dict."""
|
||||
|
||||
short_link = urllib.parse.urljoin(
|
||||
current_app.config['SHORT_LINK_BASE_URL'], short_code)
|
||||
|
||||
return {
|
||||
'short_code': short_code,
|
||||
'short_link': short_link,
|
||||
}
|
@ -142,7 +142,7 @@ def after_fetching_user(user):
|
||||
return
|
||||
|
||||
# Remove all fields except public ones.
|
||||
public_fields = {'full_name', 'username', 'email', 'extension_props_public'}
|
||||
public_fields = {'full_name', 'username', 'email', 'extension_props_public', 'badges'}
|
||||
for field in list(user.keys()):
|
||||
if field not in public_fields:
|
||||
del user[field]
|
||||
|
@ -1,9 +1,11 @@
|
||||
import logging
|
||||
|
||||
from eve.methods.get import get
|
||||
from flask import Blueprint
|
||||
from flask import Blueprint, request
|
||||
import werkzeug.exceptions as wz_exceptions
|
||||
|
||||
from pillar.api.utils import jsonify
|
||||
from pillar import current_app
|
||||
from pillar.api import utils
|
||||
from pillar.api.utils.authorization import require_login
|
||||
from pillar.auth import current_user
|
||||
|
||||
@ -15,7 +17,128 @@ blueprint_api = Blueprint('users_api', __name__)
|
||||
@require_login()
|
||||
def my_info():
|
||||
eve_resp, _, _, status, _ = get('users', {'_id': current_user.user_id})
|
||||
resp = jsonify(eve_resp['_items'][0], status=status)
|
||||
resp = utils.jsonify(eve_resp['_items'][0], status=status)
|
||||
return resp
|
||||
|
||||
|
||||
@blueprint_api.route('/video/<video_id>/progress')
|
||||
@require_login()
|
||||
def get_video_progress(video_id: str):
|
||||
"""Return video progress information.
|
||||
|
||||
Either a `204 No Content` is returned (no information stored),
|
||||
or a `200 Ok` with JSON from Eve's 'users' schema, from the key
|
||||
video.view_progress.<video_id>.
|
||||
"""
|
||||
|
||||
# Validation of the video ID; raises a BadRequest when it's not an ObjectID.
|
||||
# This isn't strictly necessary, but it makes this function behave symmetrical
|
||||
# to the set_video_progress() function.
|
||||
utils.str2id(video_id)
|
||||
|
||||
users_coll = current_app.db('users')
|
||||
user_doc = users_coll.find_one(current_user.user_id, projection={'nodes.view_progress': True})
|
||||
try:
|
||||
progress = user_doc['nodes']['view_progress'][video_id]
|
||||
except KeyError:
|
||||
return '', 204
|
||||
if not progress:
|
||||
return '', 204
|
||||
|
||||
return utils.jsonify(progress)
|
||||
|
||||
|
||||
@blueprint_api.route('/video/<video_id>/progress', methods=['POST'])
|
||||
@require_login()
|
||||
def set_video_progress(video_id: str):
|
||||
"""Save progress information about a certain video.
|
||||
|
||||
Expected parameters:
|
||||
- progress_in_sec: float number of seconds
|
||||
- progress_in_perc: integer percentage of video watched (interval [0-100])
|
||||
"""
|
||||
my_log = log.getChild('set_video_progress')
|
||||
my_log.debug('Setting video progress for user %r video %r', current_user.user_id, video_id)
|
||||
|
||||
# Constructing this response requires an active app, and thus can't be done on module load.
|
||||
no_video_response = utils.jsonify({'_message': 'No such video'}, status=404)
|
||||
|
||||
try:
|
||||
progress_in_sec = float(request.form['progress_in_sec'])
|
||||
progress_in_perc = int(request.form['progress_in_perc'])
|
||||
except KeyError as ex:
|
||||
my_log.debug('Missing POST field in request: %s', ex)
|
||||
raise wz_exceptions.BadRequest(f'missing a form field')
|
||||
except ValueError as ex:
|
||||
my_log.debug('Invalid value for POST field in request: %s', ex)
|
||||
raise wz_exceptions.BadRequest(f'Invalid value for field: {ex}')
|
||||
|
||||
users_coll = current_app.db('users')
|
||||
nodes_coll = current_app.db('nodes')
|
||||
|
||||
# First check whether this is actually an existing video
|
||||
video_oid = utils.str2id(video_id)
|
||||
video_doc = nodes_coll.find_one(video_oid, projection={
|
||||
'node_type': True,
|
||||
'properties.content_type': True,
|
||||
'properties.file': True,
|
||||
})
|
||||
if not video_doc:
|
||||
my_log.debug('Node %r not found, unable to set progress for user %r',
|
||||
video_oid, current_user.user_id)
|
||||
return no_video_response
|
||||
|
||||
try:
|
||||
is_video = (video_doc['node_type'] == 'asset'
|
||||
and video_doc['properties']['content_type'] == 'video')
|
||||
except KeyError:
|
||||
is_video = False
|
||||
|
||||
if not is_video:
|
||||
my_log.info('Node %r is not a video, unable to set progress for user %r',
|
||||
video_oid, current_user.user_id)
|
||||
# There is no video found at this URL, so act as if it doesn't even exist.
|
||||
return no_video_response
|
||||
|
||||
# Compute the progress
|
||||
percent = min(100, max(0, progress_in_perc))
|
||||
progress = {
|
||||
'progress_in_sec': progress_in_sec,
|
||||
'progress_in_percent': percent,
|
||||
'last_watched': utils.utcnow(),
|
||||
}
|
||||
|
||||
# After watching a certain percentage of the video, we consider it 'done'
|
||||
#
|
||||
# Total Credit start Total Credit Percent
|
||||
# HH:MM:SS HH:MM:SS sec sec of duration
|
||||
# Sintel 00:14:48 00:12:24 888 744 83.78%
|
||||
# Tears of Steel 00:12:14 00:09:49 734 589 80.25%
|
||||
# Cosmos Laundro 00:12:10 00:10:05 730 605 82.88%
|
||||
# Agent 327 00:03:51 00:03:26 231 206 89.18%
|
||||
# Caminandes 3 00:02:30 00:02:18 150 138 92.00%
|
||||
# Glass Half 00:03:13 00:02:52 193 172 89.12%
|
||||
# Big Buck Bunny 00:09:56 00:08:11 596 491 82.38%
|
||||
# Elephant’s 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
|
||||
|
@ -245,4 +245,10 @@ def random_etag() -> str:
|
||||
|
||||
|
||||
def utcnow() -> datetime.datetime:
|
||||
return datetime.datetime.now(tz=bson.tz_util.utc)
|
||||
"""Construct timezone-aware 'now' in UTC with millisecond precision."""
|
||||
now = datetime.datetime.now(tz=bson.tz_util.utc)
|
||||
|
||||
# MongoDB stores in millisecond precision, so truncate the microseconds.
|
||||
# This way the returned datetime can be round-tripped via MongoDB and stay the same.
|
||||
trunc_now = now.replace(microsecond=now.microsecond - (now.microsecond % 1000))
|
||||
return trunc_now
|
||||
|
@ -13,7 +13,7 @@ import logging
|
||||
import typing
|
||||
|
||||
import bson
|
||||
from flask import g, current_app
|
||||
from flask import g, current_app, session
|
||||
from flask import request
|
||||
from werkzeug import exceptions as wz_exceptions
|
||||
|
||||
@ -103,7 +103,7 @@ def find_user_in_db(user_info: dict, provider='blender-id') -> dict:
|
||||
return db_user
|
||||
|
||||
|
||||
def validate_token(*, force=False):
|
||||
def validate_token(*, force=False) -> bool:
|
||||
"""Validate the token provided in the request and populate the current_user
|
||||
flask.g object, so that permissions and access to a resource can be defined
|
||||
from it.
|
||||
@ -115,7 +115,7 @@ def validate_token(*, force=False):
|
||||
:returns: True iff the user is logged in with a valid Blender ID token.
|
||||
"""
|
||||
|
||||
from pillar.auth import AnonymousUser
|
||||
import pillar.auth
|
||||
|
||||
# Trust a pre-existing g.current_user
|
||||
if not force:
|
||||
@ -133,16 +133,22 @@ def validate_token(*, force=False):
|
||||
oauth_subclient = ''
|
||||
else:
|
||||
# Check the session, the user might be logged in through Flask-Login.
|
||||
from pillar import auth
|
||||
|
||||
token = auth.get_blender_id_oauth_token()
|
||||
# The user has a logged-in session; trust only if this request passes a CSRF check.
|
||||
# FIXME(Sybren): we should stop saving the token as 'user_id' in the sesion.
|
||||
token = session.get('user_id')
|
||||
if token:
|
||||
log.debug('skipping token check because current user already has a session')
|
||||
current_app.csrf.protect()
|
||||
else:
|
||||
token = pillar.auth.get_blender_id_oauth_token()
|
||||
oauth_subclient = None
|
||||
|
||||
if not token:
|
||||
# If no authorization headers are provided, we are getting a request
|
||||
# from a non logged in user. Proceed accordingly.
|
||||
log.debug('No authentication headers, so not logged in.')
|
||||
g.current_user = AnonymousUser()
|
||||
g.current_user = pillar.auth.AnonymousUser()
|
||||
return False
|
||||
|
||||
return validate_this_token(token, oauth_subclient) is not None
|
||||
@ -194,7 +200,7 @@ def remove_token(token: str):
|
||||
tokens_coll = current_app.db('tokens')
|
||||
token_hashed = hash_auth_token(token)
|
||||
|
||||
# TODO: remove matching on unhashed tokens once all tokens have been hashed.
|
||||
# TODO: remove matching on hashed tokens once all hashed tokens have expired.
|
||||
lookup = {'$or': [{'token': token}, {'token_hashed': token_hashed}]}
|
||||
del_res = tokens_coll.delete_many(lookup)
|
||||
log.debug('Removed token %r, matched %d documents', token, del_res.deleted_count)
|
||||
@ -206,7 +212,7 @@ def find_token(token, is_subclient_token=False, **extra_filters):
|
||||
tokens_coll = current_app.db('tokens')
|
||||
token_hashed = hash_auth_token(token)
|
||||
|
||||
# TODO: remove matching on unhashed tokens once all tokens have been hashed.
|
||||
# TODO: remove matching on hashed tokens once all hashed tokens have expired.
|
||||
lookup = {'$or': [{'token': token}, {'token_hashed': token_hashed}],
|
||||
'is_subclient_token': True if is_subclient_token else {'$in': [False, None]},
|
||||
'expire_time': {"$gt": utcnow()}}
|
||||
@ -229,8 +235,14 @@ def hash_auth_token(token: str) -> str:
|
||||
return base64.b64encode(digest).decode('ascii')
|
||||
|
||||
|
||||
def store_token(user_id, token: str, token_expiry, oauth_subclient_id=False,
|
||||
org_roles: typing.Set[str] = frozenset()):
|
||||
def store_token(user_id,
|
||||
token: str,
|
||||
token_expiry,
|
||||
oauth_subclient_id=False,
|
||||
*,
|
||||
org_roles: typing.Set[str] = frozenset(),
|
||||
oauth_scopes: typing.Optional[typing.List[str]] = None,
|
||||
):
|
||||
"""Stores an authentication token.
|
||||
|
||||
:returns: the token document from MongoDB
|
||||
@ -240,13 +252,15 @@ def store_token(user_id, token: str, token_expiry, oauth_subclient_id=False,
|
||||
|
||||
token_data = {
|
||||
'user': user_id,
|
||||
'token_hashed': hash_auth_token(token),
|
||||
'token': token,
|
||||
'expire_time': token_expiry,
|
||||
}
|
||||
if oauth_subclient_id:
|
||||
token_data['is_subclient_token'] = True
|
||||
if org_roles:
|
||||
token_data['org_roles'] = sorted(org_roles)
|
||||
if oauth_scopes:
|
||||
token_data['oauth_scopes'] = oauth_scopes
|
||||
|
||||
r, _, _, status = current_app.post_internal('tokens', token_data)
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import functools
|
||||
import typing
|
||||
|
||||
from bson import ObjectId
|
||||
from flask import g
|
||||
@ -12,8 +13,9 @@ CHECK_PERMISSIONS_IMPLEMENTED_FOR = {'projects', 'nodes', 'flamenco_jobs'}
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def check_permissions(collection_name, resource, method, append_allowed_methods=False,
|
||||
check_node_type=None):
|
||||
def check_permissions(collection_name: str, resource: dict, method: str,
|
||||
append_allowed_methods=False,
|
||||
check_node_type: typing.Optional[str] = None):
|
||||
"""Check user permissions to access a node. We look up node permissions from
|
||||
world to groups to users and match them with the computed user permissions.
|
||||
If there is not match, we raise 403.
|
||||
@ -93,8 +95,9 @@ def compute_allowed_methods(collection_name, resource, check_node_type=None):
|
||||
return allowed_methods
|
||||
|
||||
|
||||
def has_permissions(collection_name, resource, method, append_allowed_methods=False,
|
||||
check_node_type=None):
|
||||
def has_permissions(collection_name: str, resource: dict, method: str,
|
||||
append_allowed_methods=False,
|
||||
check_node_type: typing.Optional[str] = None):
|
||||
"""Check user permissions to access a node. We look up node permissions from
|
||||
world to groups to users and match them with the computed user permissions.
|
||||
|
||||
|
@ -38,6 +38,8 @@ class UserClass(flask_login.UserMixin):
|
||||
self.groups: typing.List[str] = [] # NOTE: these are stringified object IDs.
|
||||
self.group_ids: typing.List[bson.ObjectId] = []
|
||||
self.capabilities: typing.Set[str] = set()
|
||||
self.nodes: dict = {} # see the 'nodes' key in eve_settings.py::user_schema.
|
||||
self.badges_html: str = ''
|
||||
|
||||
# Lazily evaluated
|
||||
self._has_organizations: typing.Optional[bool] = None
|
||||
@ -56,6 +58,12 @@ class UserClass(flask_login.UserMixin):
|
||||
user.email = db_user.get('email') or ''
|
||||
user.username = db_user.get('username') or ''
|
||||
user.full_name = db_user.get('full_name') or ''
|
||||
user.badges_html = db_user.get('badges', {}).get('html') or ''
|
||||
|
||||
# Be a little more specific than just db_user['nodes']
|
||||
user.nodes = {
|
||||
'view_progress': db_user.get('nodes', {}).get('view_progress', {}),
|
||||
}
|
||||
|
||||
# Derived properties
|
||||
user.objectid = str(user.user_id or '')
|
||||
@ -210,6 +218,11 @@ def login_user(oauth_token: str, *, load_from_db=False):
|
||||
user = _load_user(oauth_token)
|
||||
else:
|
||||
user = UserClass(oauth_token)
|
||||
login_user_object(user)
|
||||
|
||||
|
||||
def login_user_object(user: UserClass):
|
||||
"""Log in the given user."""
|
||||
flask_login.login_user(user, remember=True)
|
||||
g.current_user = user
|
||||
user_authenticated.send(None)
|
||||
|
@ -1,8 +1,9 @@
|
||||
import abc
|
||||
import attr
|
||||
import json
|
||||
import logging
|
||||
import typing
|
||||
|
||||
import attr
|
||||
from rauth import OAuth2Service
|
||||
from flask import current_app, url_for, request, redirect, session, Response
|
||||
|
||||
@ -15,6 +16,8 @@ class OAuthUserResponse:
|
||||
|
||||
id = attr.ib(validator=attr.validators.instance_of(str))
|
||||
email = attr.ib(validator=attr.validators.instance_of(str))
|
||||
access_token = attr.ib(validator=attr.validators.instance_of(str))
|
||||
scopes: typing.List[str] = attr.ib(validator=attr.validators.instance_of(list))
|
||||
|
||||
|
||||
class OAuthError(Exception):
|
||||
@ -127,6 +130,7 @@ class OAuthSignIn(metaclass=abc.ABCMeta):
|
||||
|
||||
class BlenderIdSignIn(OAuthSignIn):
|
||||
provider_name = 'blender-id'
|
||||
scopes = ['email', 'badge']
|
||||
|
||||
def __init__(self):
|
||||
from urllib.parse import urljoin
|
||||
@ -140,12 +144,12 @@ class BlenderIdSignIn(OAuthSignIn):
|
||||
client_secret=self.consumer_secret,
|
||||
authorize_url=urljoin(base_url, 'oauth/authorize'),
|
||||
access_token_url=urljoin(base_url, 'oauth/token'),
|
||||
base_url='%s/api/' % base_url
|
||||
base_url=urljoin(base_url, 'api/'),
|
||||
)
|
||||
|
||||
def authorize(self):
|
||||
return redirect(self.service.get_authorize_url(
|
||||
scope='email',
|
||||
scope=' '.join(self.scopes),
|
||||
response_type='code',
|
||||
redirect_uri=self.get_callback_url())
|
||||
)
|
||||
@ -159,7 +163,11 @@ class BlenderIdSignIn(OAuthSignIn):
|
||||
|
||||
session['blender_id_oauth_token'] = access_token
|
||||
me = oauth_session.get('user').json()
|
||||
return OAuthUserResponse(str(me['id']), me['email'])
|
||||
|
||||
# Blender ID doesn't tell us which scopes were granted by the user, so
|
||||
# for now assume we got all the scopes we requested.
|
||||
# (see https://github.com/jazzband/django-oauth-toolkit/issues/644)
|
||||
return OAuthUserResponse(str(me['id']), me['email'], access_token, self.scopes)
|
||||
|
||||
|
||||
class FacebookSignIn(OAuthSignIn):
|
||||
@ -189,7 +197,7 @@ class FacebookSignIn(OAuthSignIn):
|
||||
me = oauth_session.get('me?fields=id,email').json()
|
||||
# TODO handle case when user chooses not to disclose en email
|
||||
# see https://developers.facebook.com/docs/graph-api/reference/user/
|
||||
return OAuthUserResponse(me['id'], me.get('email'))
|
||||
return OAuthUserResponse(me['id'], me.get('email'), '', [])
|
||||
|
||||
|
||||
class GoogleSignIn(OAuthSignIn):
|
||||
@ -217,4 +225,4 @@ class GoogleSignIn(OAuthSignIn):
|
||||
oauth_session = self.make_oauth_session()
|
||||
|
||||
me = oauth_session.get('userinfo').json()
|
||||
return OAuthUserResponse(str(me['id']), me['email'])
|
||||
return OAuthUserResponse(str(me['id']), me['email'], '', [])
|
||||
|
183
pillar/badge_sync.py
Normal file
183
pillar/badge_sync.py
Normal 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
20
pillar/celery/badges.py
Normal 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)
|
@ -13,6 +13,7 @@ from pillar.cli.maintenance import manager_maintenance
|
||||
from pillar.cli.operations import manager_operations
|
||||
from pillar.cli.setup import manager_setup
|
||||
from pillar.cli.elastic import manager_elastic
|
||||
from . import badges
|
||||
|
||||
from pillar.cli import translations
|
||||
|
||||
@ -24,3 +25,4 @@ manager.add_command("maintenance", manager_maintenance)
|
||||
manager.add_command("setup", manager_setup)
|
||||
manager.add_command("operations", manager_operations)
|
||||
manager.add_command("elastic", manager_elastic)
|
||||
manager.add_command("badges", badges.manager)
|
||||
|
39
pillar/cli/badges.py
Normal file
39
pillar/cli/badges.py
Normal 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)
|
@ -559,50 +559,6 @@ def replace_pillar_node_type_schemas(project_url=None, all_projects=False, missi
|
||||
projects_changed, projects_seen)
|
||||
|
||||
|
||||
@manager_maintenance.command
|
||||
def remarkdown_comments():
|
||||
"""Retranslates all Markdown to HTML for all comment nodes.
|
||||
"""
|
||||
|
||||
from pillar.api.nodes import convert_markdown
|
||||
|
||||
nodes_collection = current_app.db()['nodes']
|
||||
comments = nodes_collection.find({'node_type': 'comment'},
|
||||
projection={'properties.content': 1,
|
||||
'node_type': 1})
|
||||
|
||||
updated = identical = skipped = errors = 0
|
||||
for node in comments:
|
||||
convert_markdown(node)
|
||||
node_id = node['_id']
|
||||
|
||||
try:
|
||||
content_html = node['properties']['content_html']
|
||||
except KeyError:
|
||||
log.warning('Node %s has no content_html', node_id)
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
result = nodes_collection.update_one(
|
||||
{'_id': node_id},
|
||||
{'$set': {'properties.content_html': content_html}}
|
||||
)
|
||||
if result.matched_count != 1:
|
||||
log.error('Unable to update node %s', node_id)
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
if result.modified_count:
|
||||
updated += 1
|
||||
else:
|
||||
identical += 1
|
||||
|
||||
log.info('updated : %i', updated)
|
||||
log.info('identical: %i', identical)
|
||||
log.info('skipped : %i', skipped)
|
||||
log.info('errors : %i', errors)
|
||||
|
||||
|
||||
@manager_maintenance.option('-p', '--project', dest='proj_url', nargs='?',
|
||||
help='Project URL')
|
||||
@manager_maintenance.option('-a', '--all', dest='all_projects', action='store_true', default=False,
|
||||
|
@ -1,6 +1,8 @@
|
||||
from collections import defaultdict
|
||||
import datetime
|
||||
import os.path
|
||||
from os import getenv
|
||||
from collections import defaultdict
|
||||
|
||||
import requests.certs
|
||||
|
||||
# Certificate file for communication with other systems.
|
||||
@ -29,6 +31,7 @@ DEBUG = False
|
||||
SECRET_KEY = ''
|
||||
|
||||
# Authentication token hashing key. If empty falls back to UTF8-encoded SECRET_KEY with a warning.
|
||||
# Not used to hash new tokens, but it is used to check pre-existing hashed tokens.
|
||||
AUTH_TOKEN_HMAC_KEY = b''
|
||||
|
||||
# Authentication settings
|
||||
@ -203,8 +206,18 @@ CELERY_BEAT_SCHEDULE = {
|
||||
'schedule': 600, # every N seconds
|
||||
'args': ('gcs', 100)
|
||||
},
|
||||
'refresh-blenderid-badges': {
|
||||
'task': 'pillar.celery.badges.sync_badges_for_users',
|
||||
'schedule': 600, # every N seconds
|
||||
'args': (540, ), # time limit in seconds, keep shorter than 'schedule'
|
||||
}
|
||||
}
|
||||
|
||||
# Badges will be re-fetched every timedelta.
|
||||
# TODO(Sybren): A proper value should be determined after we actually have users with badges.
|
||||
BLENDER_ID_BADGE_EXPIRY = datetime.timedelta(hours=4)
|
||||
|
||||
|
||||
# Mapping from user role to capabilities obtained by users with that role.
|
||||
USER_CAPABILITIES = defaultdict(**{
|
||||
'subscriber': {'subscriber', 'home-project'},
|
||||
|
@ -162,9 +162,12 @@ class YouTube:
|
||||
if not youtube_id:
|
||||
return html_module.escape('{youtube invalid YouTube ID/URL}')
|
||||
|
||||
src = f'https://www.youtube.com/embed/{youtube_id}?rel=0'
|
||||
html = f'<iframe class="shortcode youtube" width="{width}" height="{height}" src="{src}"' \
|
||||
f' frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>'
|
||||
src = f'https://www.youtube.com/embed/{youtube_id}?rel=0'
|
||||
html = f'<div class="embed-responsive embed-responsive-16by9">' \
|
||||
f'<iframe class="shortcode youtube embed-responsive-item"' \
|
||||
f' width="{width}" height="{height}" src="{src}"' \
|
||||
f' frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>' \
|
||||
f'</div>'
|
||||
return html
|
||||
|
||||
|
||||
@ -225,12 +228,25 @@ class Attachment:
|
||||
|
||||
return self.render(file_doc, pargs, kwargs)
|
||||
|
||||
def sdk_file(self, slug: str, node_properties: dict) -> pillarsdk.File:
|
||||
def sdk_file(self, slug: str, document: dict) -> pillarsdk.File:
|
||||
"""Return the file document for the attachment with this slug."""
|
||||
|
||||
from pillar.web import system_util
|
||||
|
||||
attachments = node_properties.get('attachments', {})
|
||||
# TODO (fsiddi) Make explicit what 'document' is.
|
||||
# In some cases we pass the entire node or project documents, in other cases
|
||||
# we pass node.properties. This should be unified at the level of do_markdown.
|
||||
# For now we do a quick hack and first look for 'properties' in the doc,
|
||||
# then we look for 'attachments'.
|
||||
|
||||
doc_properties = document.get('properties')
|
||||
if doc_properties:
|
||||
# We passed an entire document (all nodes must have 'properties')
|
||||
attachments = doc_properties.get('attachments', {})
|
||||
else:
|
||||
# The value of document could have been defined as 'node.properties'
|
||||
attachments = document.get('attachments', {})
|
||||
|
||||
attachment = attachments.get(slug)
|
||||
if not attachment:
|
||||
raise self.NoSuchSlug(slug)
|
||||
|
@ -1,6 +1,7 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import copy
|
||||
import datetime
|
||||
import json
|
||||
@ -10,11 +11,7 @@ import pathlib
|
||||
import sys
|
||||
import typing
|
||||
import unittest.mock
|
||||
|
||||
try:
|
||||
from urllib.parse import urlencode
|
||||
except ImportError:
|
||||
from urllib.parse import urlencode
|
||||
from urllib.parse import urlencode, urljoin
|
||||
|
||||
from bson import ObjectId, tz_util
|
||||
|
||||
@ -186,7 +183,7 @@ class AbstractPillarTest(TestMinimal):
|
||||
else:
|
||||
self.ensure_project_exists()
|
||||
|
||||
with self.app.test_request_context():
|
||||
with self.app.app_context():
|
||||
files_collection = self.app.data.driver.db['files']
|
||||
assert isinstance(files_collection, pymongo.collection.Collection)
|
||||
|
||||
@ -327,15 +324,46 @@ class AbstractPillarTest(TestMinimal):
|
||||
|
||||
return user
|
||||
|
||||
def create_valid_auth_token(self, user_id, token='token'):
|
||||
@contextlib.contextmanager
|
||||
def login_as(self, user_id: typing.Union[str, ObjectId]):
|
||||
"""Context manager, within the context the app context is active and the user logged in.
|
||||
|
||||
The logging-in happens when a request starts, so it's only active when
|
||||
e.g. self.get() or self.post() or somesuch request is used.
|
||||
"""
|
||||
from pillar.auth import UserClass, login_user_object
|
||||
|
||||
if isinstance(user_id, str):
|
||||
user_oid = ObjectId(user_id)
|
||||
elif isinstance(user_id, ObjectId):
|
||||
user_oid = user_id
|
||||
else:
|
||||
raise TypeError(f'invalid type {type(user_id)} for parameter user_id')
|
||||
user_doc = self.fetch_user_from_db(user_oid)
|
||||
|
||||
def signal_handler(sender, **kwargs):
|
||||
login_user_object(user)
|
||||
|
||||
with self.app.app_context():
|
||||
user = UserClass.construct('', user_doc)
|
||||
with flask.request_started.connected_to(signal_handler, self.app):
|
||||
yield
|
||||
|
||||
# TODO: rename to 'create_auth_token' now that 'expire_in_days' can be negative.
|
||||
def create_valid_auth_token(self,
|
||||
user_id: ObjectId,
|
||||
token='token',
|
||||
*,
|
||||
oauth_scopes: typing.Optional[typing.List[str]]=None,
|
||||
expire_in_days=1) -> dict:
|
||||
from pillar.api.utils import utcnow
|
||||
|
||||
future = utcnow() + datetime.timedelta(days=1)
|
||||
future = utcnow() + datetime.timedelta(days=expire_in_days)
|
||||
|
||||
with self.app.test_request_context():
|
||||
from pillar.api.utils import authentication as auth
|
||||
|
||||
token_data = auth.store_token(user_id, token, future, None)
|
||||
token_data = auth.store_token(user_id, token, future, oauth_scopes=oauth_scopes)
|
||||
|
||||
return token_data
|
||||
|
||||
@ -365,7 +393,7 @@ class AbstractPillarTest(TestMinimal):
|
||||
|
||||
return user_id
|
||||
|
||||
def create_node(self, node_doc):
|
||||
def create_node(self, node_doc) -> ObjectId:
|
||||
"""Creates a node, returning its ObjectId. """
|
||||
|
||||
with self.app.test_request_context():
|
||||
@ -407,7 +435,7 @@ class AbstractPillarTest(TestMinimal):
|
||||
"""Sets up Responses to mock unhappy validation flow."""
|
||||
|
||||
responses.add(responses.POST,
|
||||
'%s/u/validate_token' % self.app.config['BLENDER_ID_ENDPOINT'],
|
||||
urljoin(self.app.config['BLENDER_ID_ENDPOINT'], 'u/validate_token'),
|
||||
json={'status': 'fail'},
|
||||
status=403)
|
||||
|
||||
@ -415,7 +443,7 @@ class AbstractPillarTest(TestMinimal):
|
||||
"""Sets up Responses to mock happy validation flow."""
|
||||
|
||||
responses.add(responses.POST,
|
||||
'%s/u/validate_token' % self.app.config['BLENDER_ID_ENDPOINT'],
|
||||
urljoin(self.app.config['BLENDER_ID_ENDPOINT'], 'u/validate_token'),
|
||||
json=BLENDER_ID_USER_RESPONSE,
|
||||
status=200)
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
"""Flask configuration file for unit testing."""
|
||||
|
||||
BLENDER_ID_ENDPOINT = 'http://id.local:8001' # Non existant server
|
||||
BLENDER_ID_ENDPOINT = 'http://id.local:8001/' # Non existant server
|
||||
|
||||
SERVER_NAME = 'localhost.local'
|
||||
PILLAR_SERVER_ENDPOINT = 'http://localhost.local/api/'
|
||||
|
@ -19,6 +19,7 @@ from pillar.web.nodes.routes import url_for_node
|
||||
from pillar.web.nodes.forms import get_node_form
|
||||
import pillar.web.nodes.attachments
|
||||
from pillar.web.projects.routes import project_update_nodes_list
|
||||
from pillar.web.projects.routes import project_navigation_links
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@ -61,16 +62,10 @@ def posts_view(project_id=None, project_url=None, url=None, *, archive=False, pa
|
||||
post.picture = get_file(post.picture, api=api)
|
||||
post.url = url_for_node(node=post)
|
||||
|
||||
# Use the *_main_project.html template for the main blog
|
||||
is_main_project = project_id == current_app.config['MAIN_PROJECT_ID']
|
||||
main_project_template = '_main_project' if is_main_project else ''
|
||||
main_project_template = '_main_project'
|
||||
index_arch = 'archive' if archive else 'index'
|
||||
template_path = f'nodes/custom/blog/{index_arch}{main_project_template}.html',
|
||||
template_path = f'nodes/custom/blog/{index_arch}.html',
|
||||
|
||||
if url:
|
||||
template_path = f'nodes/custom/post/view{main_project_template}.html',
|
||||
|
||||
post = Node.find_one({
|
||||
'where': {'parent': blog._id, 'properties.url': url},
|
||||
'embedded': {'node_type': 1, 'user': 1},
|
||||
@ -95,6 +90,7 @@ def posts_view(project_id=None, project_url=None, url=None, *, archive=False, pa
|
||||
can_create_blog_posts = project.node_type_has_method('post', 'POST', api=api)
|
||||
|
||||
# Use functools.partial so we can later pass page=X.
|
||||
is_main_project = project_id == current_app.config['MAIN_PROJECT_ID']
|
||||
if is_main_project:
|
||||
url_func = functools.partial(url_for, 'main.main_blog_archive')
|
||||
else:
|
||||
@ -112,24 +108,19 @@ def posts_view(project_id=None, project_url=None, url=None, *, archive=False, pa
|
||||
else:
|
||||
project.blog_archive_prev = None
|
||||
|
||||
title = 'blog_main' if is_main_project else 'blog'
|
||||
|
||||
pages = Node.all({
|
||||
'where': {'project': project._id, 'node_type': 'page'},
|
||||
'projection': {'name': 1}}, api=api)
|
||||
navigation_links = project_navigation_links(project, api)
|
||||
|
||||
return render_template(
|
||||
template_path,
|
||||
blog=blog,
|
||||
node=post,
|
||||
node=post, # node is used by the generic comments rendering (see custom/_scripts.pug)
|
||||
posts=posts._items,
|
||||
posts_meta=pmeta,
|
||||
more_posts_available=pmeta['total'] > pmeta['max_results'],
|
||||
project=project,
|
||||
title=title,
|
||||
node_type_post=project.get_node_type('post'),
|
||||
can_create_blog_posts=can_create_blog_posts,
|
||||
pages=pages._items,
|
||||
navigation_links=navigation_links,
|
||||
api=api)
|
||||
|
||||
|
||||
|
@ -94,6 +94,16 @@ def find_for_post(project, node):
|
||||
url=node.properties.url)
|
||||
|
||||
|
||||
@register_node_finder('page')
|
||||
def find_for_page(project, node):
|
||||
"""Returns the URL for a page."""
|
||||
|
||||
project_id = project['_id']
|
||||
|
||||
the_project = project_url(project_id, project=project)
|
||||
return url_for('projects.view_node', project_url=the_project.url, node_id=node.properties.url)
|
||||
|
||||
|
||||
def find_for_other(project, node):
|
||||
"""Fallback: Assets, textures, and other node types.
|
||||
|
||||
|
@ -1,9 +1,10 @@
|
||||
import functools
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import date
|
||||
import pillarsdk
|
||||
from flask import current_app
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField
|
||||
from wtforms import DateField
|
||||
@ -17,6 +18,8 @@ from wtforms import DateTimeField
|
||||
from wtforms import SelectMultipleField
|
||||
from wtforms import FieldList
|
||||
from wtforms.validators import DataRequired
|
||||
|
||||
from pillar import current_app
|
||||
from pillar.web.utils import system_util
|
||||
from pillar.web.utils.forms import FileSelectField
|
||||
from pillar.web.utils.forms import CustomFormField
|
||||
@ -44,6 +47,13 @@ def iter_node_properties(node_type):
|
||||
yield prop_name, prop_schema, prop_fschema
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def tag_choices() -> typing.List[typing.Tuple[str, str]]:
|
||||
"""Return (value, label) tuples for the NODE_TAGS config setting."""
|
||||
tags = current_app.config.get('NODE_TAGS') or []
|
||||
return [(tag, tag.title()) for tag in tags] # (value, label) tuples
|
||||
|
||||
|
||||
def add_form_properties(form_class, node_type):
|
||||
"""Add fields to a form based on the node and form schema provided.
|
||||
:type node_schema: dict
|
||||
@ -60,7 +70,9 @@ def add_form_properties(form_class, node_type):
|
||||
# Recursive call if detects a dict
|
||||
field_type = schema_prop['type']
|
||||
|
||||
if field_type == 'dict':
|
||||
if prop_name == 'tags' and field_type == 'list':
|
||||
field = SelectMultipleField(choices=tag_choices())
|
||||
elif field_type == 'dict':
|
||||
assert prop_name == 'attachments'
|
||||
field = attachments.attachment_form_group_create(schema_prop)
|
||||
elif field_type == 'list':
|
||||
|
@ -24,6 +24,7 @@ from pillar import current_app
|
||||
from pillar.api.utils import utcnow
|
||||
from pillar.web import system_util
|
||||
from pillar.web import utils
|
||||
from pillar.web.nodes import finders
|
||||
from pillar.web.utils.jstree import jstree_get_children
|
||||
import pillar.extension
|
||||
|
||||
@ -302,6 +303,51 @@ def view(project_url):
|
||||
'header_video_node': header_video_node})
|
||||
|
||||
|
||||
def project_navigation_links(project, api) -> list:
|
||||
"""Returns a list of nodes for the project, for top navigation display.
|
||||
|
||||
Args:
|
||||
project: A Project object.
|
||||
api: the api client credential.
|
||||
|
||||
Returns:
|
||||
A list of links for the Project.
|
||||
For example we display a link to the project blog if present, as well
|
||||
as pages. The list is structured as follows:
|
||||
|
||||
[{'url': '/p/spring/about', 'label': 'About'},
|
||||
{'url': '/p/spring/blog', 'label': 'Blog'}]
|
||||
"""
|
||||
|
||||
links = []
|
||||
|
||||
# Fetch the blog
|
||||
blog = Node.find_first({
|
||||
'where': {'project': project._id, 'node_type': 'blog', '_deleted': {'$ne': True}},
|
||||
'projection': {
|
||||
'name': 1,
|
||||
}
|
||||
}, api=api)
|
||||
|
||||
if blog:
|
||||
links.append({'url': finders.find_url_for_node(blog), 'label': blog.name, 'slug': 'blog'})
|
||||
|
||||
# Fetch pages
|
||||
pages = Node.all({
|
||||
'where': {'project': project._id, 'node_type': 'page', '_deleted': {'$ne': True}},
|
||||
'projection': {
|
||||
'name': 1,
|
||||
'properties.url': 1
|
||||
}
|
||||
}, api=api)
|
||||
|
||||
# Process the results and append the links to the list
|
||||
for p in pages._items:
|
||||
links.append({'url': finders.find_url_for_node(p), 'label': p.name, 'slug': p.properties.url})
|
||||
|
||||
return links
|
||||
|
||||
|
||||
def render_project(project, api, extra_context=None, template_name=None):
|
||||
project.picture_square = utils.get_file(project.picture_square, api=api)
|
||||
project.picture_header = utils.get_file(project.picture_header, api=api)
|
||||
@ -370,6 +416,8 @@ def render_project(project, api, extra_context=None, template_name=None):
|
||||
|
||||
extension_sidebar_links = current_app.extension_sidebar_links(project)
|
||||
|
||||
navigation_links = project_navigation_links(project, api)
|
||||
|
||||
return render_template(template_name,
|
||||
api=api,
|
||||
project=project,
|
||||
@ -378,6 +426,7 @@ def render_project(project, api, extra_context=None, template_name=None):
|
||||
show_project=True,
|
||||
og_picture=project.picture_header,
|
||||
activity_stream=activity_stream,
|
||||
navigation_links=navigation_links,
|
||||
extension_sidebar_links=extension_sidebar_links,
|
||||
**extra_context)
|
||||
|
||||
@ -447,16 +496,14 @@ def view_node(project_url, node_id):
|
||||
|
||||
# Append _theatre to load the proper template
|
||||
theatre = '_theatre' if theatre_mode else ''
|
||||
navigation_links = project_navigation_links(project, api)
|
||||
|
||||
if node.node_type == 'page':
|
||||
pages = Node.all({
|
||||
'where': {'project': project._id, 'node_type': 'page'},
|
||||
'projection': {'name': 1}}, api=api)
|
||||
return render_template('nodes/custom/page/view_embed.html',
|
||||
api=api,
|
||||
node=node,
|
||||
project=project,
|
||||
pages=pages._items,
|
||||
navigation_links=navigation_links,
|
||||
og_picture=og_picture,)
|
||||
|
||||
extension_sidebar_links = current_app.extension_sidebar_links(project)
|
||||
@ -468,6 +515,7 @@ def view_node(project_url, node_id):
|
||||
show_node=True,
|
||||
show_project=False,
|
||||
og_picture=og_picture,
|
||||
navigation_links=navigation_links,
|
||||
extension_sidebar_links=extension_sidebar_links)
|
||||
|
||||
|
||||
|
@ -872,12 +872,6 @@
|
||||
"code": 61930,
|
||||
"src": "fontawesome"
|
||||
},
|
||||
{
|
||||
"uid": "31972e4e9d080eaa796290349ae6c1fd",
|
||||
"css": "users",
|
||||
"code": 59502,
|
||||
"src": "fontawesome"
|
||||
},
|
||||
{
|
||||
"uid": "c8585e1e5b0467f28b70bce765d5840c",
|
||||
"css": "clipboard-copy",
|
||||
@ -990,6 +984,30 @@
|
||||
"code": 59394,
|
||||
"src": "entypo"
|
||||
},
|
||||
{
|
||||
"uid": "347c38a8b96a509270fdcabc951e7571",
|
||||
"css": "database",
|
||||
"code": 61888,
|
||||
"src": "fontawesome"
|
||||
},
|
||||
{
|
||||
"uid": "3a6f0140c3a390bdb203f56d1bfdefcb",
|
||||
"css": "speed",
|
||||
"code": 59471,
|
||||
"src": "entypo"
|
||||
},
|
||||
{
|
||||
"uid": "4c1ef492f1d2c39a2250ae457cee2a6e",
|
||||
"css": "social-instagram",
|
||||
"code": 61805,
|
||||
"src": "fontawesome"
|
||||
},
|
||||
{
|
||||
"uid": "e36d581e4f2844db345bddc205d15dda",
|
||||
"css": "users",
|
||||
"code": 59507,
|
||||
"src": "elusive"
|
||||
},
|
||||
{
|
||||
"uid": "053a214a098a9453877363eeb45f004e",
|
||||
"css": "log-in",
|
||||
|
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -33,7 +33,8 @@ def get_user_info(user_id):
|
||||
# TODO: put those fields into a config var or module-level global.
|
||||
return {'email': user.email,
|
||||
'full_name': user.full_name,
|
||||
'username': user.username}
|
||||
'username': user.username,
|
||||
'badges_html': (user.badges and user.badges.html) or ''}
|
||||
|
||||
|
||||
def setup_app(app):
|
||||
|
@ -48,6 +48,10 @@ def oauth_authorize(provider):
|
||||
|
||||
@blueprint.route('/oauth/<provider>/authorized')
|
||||
def oauth_callback(provider):
|
||||
import datetime
|
||||
from pillar.api.utils.authentication import store_token
|
||||
from pillar.api.utils import utcnow
|
||||
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.homepage'))
|
||||
|
||||
@ -65,7 +69,17 @@ def oauth_callback(provider):
|
||||
user_info = {'id': oauth_user.id, 'email': oauth_user.email, 'full_name': ''}
|
||||
db_user = find_user_in_db(user_info, provider=provider)
|
||||
db_id, status = upsert_user(db_user)
|
||||
token = generate_and_store_token(db_id)
|
||||
|
||||
# TODO(Sybren): If the user doesn't have any badges, but the access token
|
||||
# does have 'badge' scope, we should fetch the badges in the background.
|
||||
|
||||
if oauth_user.access_token:
|
||||
# TODO(Sybren): make nr of days configurable, or get from OAuthSignIn subclass.
|
||||
token_expiry = utcnow() + datetime.timedelta(days=15)
|
||||
token = store_token(db_id, oauth_user.access_token, token_expiry,
|
||||
oauth_scopes=oauth_user.scopes)
|
||||
else:
|
||||
token = generate_and_store_token(db_id)
|
||||
|
||||
# Login user
|
||||
pillar.auth.login_user(token['token'], load_from_db=True)
|
||||
|
@ -62,7 +62,7 @@ def jstree_get_children(node_id, project_id=None):
|
||||
'where': {
|
||||
'$and': [
|
||||
{'node_type': {'$regex': '^(?!attract_)'}},
|
||||
{'node_type': {'$not': {'$in': ['comment', 'post']}}},
|
||||
{'node_type': {'$not': {'$in': ['comment', 'post', 'blog', 'page']}}},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -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
@ -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">↩</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, "&");
|
||||
code = code.replace(/</g, "<");
|
||||
code = code.replace(/>/g, ">");
|
||||
// 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, "—").replace(/--/g, "–");
|
||||
// Ellipses
|
||||
text = text.replace(/\.\.\./g, "…").replace(/\.\s\.\s\./g, "…");
|
||||
// Backticks
|
||||
text = text.replace(/``/g, "“").replace (/''/g, "”");
|
||||
|
||||
if(/^'$/.test(text)) {
|
||||
// Special case: single-character ' token
|
||||
if(/\S/.test(this.smartyPantsLastChar)) {
|
||||
return "’";
|
||||
}
|
||||
return "‘";
|
||||
}
|
||||
if(/^"$/.test(text)) {
|
||||
// Special case: single-character " token
|
||||
if(/\S/.test(this.smartyPantsLastChar)) {
|
||||
return "”";
|
||||
}
|
||||
return "“";
|
||||
}
|
||||
|
||||
// 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)/, "’");
|
||||
text = text.replace (/^"(?=[!"#\$\%'()*+,\-.\/:;<=>?\@\[\\]\^_`{|}~]\B)/, "”");
|
||||
|
||||
// Special case for double sets of quotes, e.g.:
|
||||
// <p>He said, "'Quoted' words in a larger quote."</p>
|
||||
text = text.replace(/"'(?=\w)/g, "“‘");
|
||||
text = text.replace(/'"(?=\w)/g, "‘“");
|
||||
|
||||
// Special case for decade abbreviations (the '80s):
|
||||
text = text.replace(/'(?=\d{2}s)/g, "’");
|
||||
|
||||
// Get most opening single quotes:
|
||||
text = text.replace(/(\s| |--|&[mn]dash;|&\#8211;|&\#8212;|&\#x201[34];)'(?=\w)/g, "$1‘");
|
||||
|
||||
// Single closing quotes:
|
||||
text = text.replace(/([^\s\[\{\(\-])'/g, "$1’");
|
||||
text = text.replace(/'(?=\s|s\b)/g, "’");
|
||||
|
||||
// Any remaining single quotes should be opening ones:
|
||||
text = text.replace(/'/g, "‘");
|
||||
|
||||
// Get most opening double quotes:
|
||||
text = text.replace(/(\s| |--|&[mn]dash;|&\#8211;|&\#8212;|&\#x201[34];)"(?=\w)/g, "$1“");
|
||||
|
||||
// Double closing quotes:
|
||||
text = text.replace(/([^\s\[\{\(\-])"/g, "$1”");
|
||||
text = text.replace(/"(?=\s)/g, "”");
|
||||
|
||||
// Any remaining quotes should be opening ones.
|
||||
text = text.replace(/"/ig, "“");
|
||||
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";
|
||||
});
|
||||
};
|
||||
|
||||
})();
|
||||
|
@ -64,4 +64,13 @@
|
||||
return this;
|
||||
};
|
||||
|
||||
// jQuery's show() sets display as 'inline', this utility sets it to whatever we want.
|
||||
// Useful for buttons or links that need 'inline-block' or flex for correct padding and alignment.
|
||||
$.fn.displayAs = function(display_type) {
|
||||
if (typeof(display_type) === 'undefined') {
|
||||
display_type = 'block';
|
||||
}
|
||||
|
||||
this.css('display', display_type);
|
||||
}
|
||||
}(jQuery));
|
||||
|
@ -32,7 +32,7 @@ var DocumentTitleAPI = {
|
||||
};
|
||||
|
||||
|
||||
/* Status Bar */
|
||||
/* Status Bar * DEPRECATED * USE TOASTR INSTEAD */
|
||||
function statusBarClear(delay_class, delay_html){
|
||||
var statusBar = $("#status-bar");
|
||||
|
||||
@ -54,6 +54,7 @@ function statusBarClear(delay_class, delay_html){
|
||||
}
|
||||
}
|
||||
|
||||
/* Status Bar * DEPRECATED - USE TOASTR INSTEAD * */
|
||||
function statusBarSet(classes, html, icon_name, time){
|
||||
/* Utility to notify the user by temporarily flashing text on the project header
|
||||
Usage:
|
||||
|
@ -66,12 +66,9 @@ function containerResizeY(window_height){
|
||||
|
||||
var project_container = document.getElementById('project-container');
|
||||
var container_offset = project_container.offsetTop;
|
||||
var nav_header_height = $('#project_nav-header').height();
|
||||
var container_height = window_height - container_offset.top;
|
||||
var container_height_wheader = window_height - container_offset.top - nav_header_height;
|
||||
var window_height_minus_nav = window_height - nav_header_height - 1; // 1 is border width
|
||||
|
||||
$('#project_context-header').width($('#project_context-container').width());
|
||||
var container_height_wheader = window_height - container_offset;
|
||||
var window_height_minus_nav = window_height - container_offset;
|
||||
|
||||
if ($(window).width() > 768) {
|
||||
$('#project-container').css(
|
||||
@ -79,13 +76,14 @@ function containerResizeY(window_height){
|
||||
'height': window_height_minus_nav + 'px'}
|
||||
);
|
||||
|
||||
$('#project_nav-container, #project_tree, .project_split').css(
|
||||
{'max-height': (window_height_minus_nav - 50) + 'px',
|
||||
'height': (window_height_minus_nav - 50) + 'px'}
|
||||
$('#project_nav-container, #project_tree').css(
|
||||
{'max-height': (window_height_minus_nav) + 'px',
|
||||
'height': (window_height_minus_nav) + 'px'}
|
||||
);
|
||||
|
||||
if (container_height > parseInt($('#project-container').css("min-height"))) {
|
||||
if (typeof projectTree !== "undefined"){
|
||||
|
||||
$(projectTree).css(
|
||||
{'max-height': container_height_wheader + 'px',
|
||||
'height': container_height_wheader + 'px'}
|
||||
|
@ -40,11 +40,6 @@ $(document).on('click','body .comment-action-reply',function(e){
|
||||
parentDiv.after(commentForm);
|
||||
// document.getElementById('comment_field').focus();
|
||||
$(commentField).focus();
|
||||
|
||||
// Convert Markdown
|
||||
var convert = new Markdown.getSanitizingConverter().makeHtml;
|
||||
var preview = $('.comment-reply-preview-md');
|
||||
preview.html(convert($(commentField).val()));
|
||||
$('.comment-reply-field').addClass('filled');
|
||||
});
|
||||
|
||||
@ -59,10 +54,6 @@ $(document).on('click','body .comment-action-cancel',function(e){
|
||||
delete commentField.dataset.originalParentId;
|
||||
|
||||
$(commentField).val('');
|
||||
// Convert Markdown
|
||||
var convert = new Markdown.getSanitizingConverter().makeHtml;
|
||||
var preview = $('.comment-reply-preview-md');
|
||||
preview.html(convert($(commentField).val()));
|
||||
|
||||
$('.comment-reply-field').removeClass('filled');
|
||||
$('.comment-container').removeClass('is-replying');
|
||||
|
202
src/scripts/video_plugins.js
Normal file
202
src/scripts/video_plugins.js
Normal 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);
|
@ -143,12 +143,17 @@ nav.sidebar
|
||||
left: 0
|
||||
width: $sidebar-width
|
||||
height: 100%
|
||||
background-color: $color-background-nav
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
> ul > li > .navbar-item
|
||||
padding-top: 10px
|
||||
padding-bottom: 10px
|
||||
background: red
|
||||
|
||||
.dropdown
|
||||
min-width: $sidebar-width
|
||||
|
||||
.dropdown-menu
|
||||
top: initial
|
||||
bottom: 3px
|
||||
@ -159,7 +164,7 @@ nav.sidebar
|
||||
li a
|
||||
justify-content: flex-start
|
||||
|
||||
ul
|
||||
> ul
|
||||
width: 100%
|
||||
margin: 0
|
||||
padding: 0
|
||||
@ -172,25 +177,11 @@ nav.sidebar
|
||||
|
||||
a.navbar-item, button
|
||||
display: flex
|
||||
color: $color-text-light-hint
|
||||
font-size: 1.5em
|
||||
align-items: center
|
||||
justify-content: center
|
||||
padding: 10px 0
|
||||
background: transparent
|
||||
border: none
|
||||
width: 100%
|
||||
text-decoration: none
|
||||
|
||||
&:hover
|
||||
color: $color-text-light-primary
|
||||
&:active
|
||||
outline: none
|
||||
|
||||
&.cloud
|
||||
i
|
||||
position: relative
|
||||
left: -4px
|
||||
|
||||
a.dropdown-toggle
|
||||
padding: 0
|
||||
@ -408,3 +399,68 @@ nav.sidebar
|
||||
top: -1px
|
||||
left: -19px
|
||||
z-index: 1
|
||||
|
||||
$loader-bar-width: 100px
|
||||
$loader-bar-height: 2px
|
||||
.loader-bar
|
||||
bottom: -$loader-bar-height
|
||||
content: ''
|
||||
display: none
|
||||
height: 0
|
||||
overflow: hidden
|
||||
position: absolute
|
||||
visibility: hidden
|
||||
width: 100%
|
||||
z-index: 20
|
||||
|
||||
&:before
|
||||
animation: none
|
||||
background-color: $primary
|
||||
background-image: linear-gradient(to right, $primary-accent, $primary)
|
||||
content: ''
|
||||
display: block
|
||||
height: $loader-bar-height
|
||||
left: -$loader-bar-width
|
||||
position: absolute
|
||||
width: $loader-bar-width
|
||||
|
||||
&.active
|
||||
display: block
|
||||
height: $loader-bar-height
|
||||
visibility: visible
|
||||
|
||||
&:before
|
||||
animation: loader-bar-slide 2s linear infinite
|
||||
|
||||
@keyframes loader-bar-slide
|
||||
from
|
||||
left: -($loader-bar-width / 2)
|
||||
width: 3%
|
||||
|
||||
50%
|
||||
width: 20%
|
||||
|
||||
70%
|
||||
width: 70%
|
||||
|
||||
80%
|
||||
left: 50%
|
||||
|
||||
95%
|
||||
left: 120%
|
||||
|
||||
to
|
||||
left: 100%
|
||||
|
||||
.progress-bar
|
||||
background-color: $primary
|
||||
background-image: linear-gradient(to right, $primary-accent, $primary)
|
||||
|
||||
.node-details-description
|
||||
+node-details-description
|
||||
|
||||
@include media-breakpoint-up(lg)
|
||||
max-width: map-get($grid-breakpoints, "md")
|
||||
|
||||
@include media-breakpoint-up(xl)
|
||||
max-width: map-get($grid-breakpoints, "lg")
|
||||
|
@ -1,7 +1,9 @@
|
||||
$comments-width-max: 710px
|
||||
|
||||
.comments-container
|
||||
max-width: $comments-width-max
|
||||
position: relative
|
||||
width: 100%
|
||||
|
||||
#comments-reload
|
||||
text-align: center
|
||||
@ -30,7 +32,7 @@ $comments-width-max: 710px
|
||||
.comment-reply-container
|
||||
display: flex
|
||||
position: relative
|
||||
padding: 15px 0 20px 0
|
||||
padding: 15px 0
|
||||
transition: background-color 150ms ease-in-out, padding 150ms ease-in-out, margin 150ms ease-in-out
|
||||
|
||||
&.comment-linked
|
||||
@ -194,8 +196,6 @@ $comments-width-max: 710px
|
||||
cursor: pointer
|
||||
font-family: 'pillar-font'
|
||||
height: 25px
|
||||
position: relative
|
||||
top: 4px
|
||||
width: 16px
|
||||
|
||||
.comment-action-rating.up
|
||||
@ -284,10 +284,11 @@ $comments-width-max: 710px
|
||||
color: $color-success
|
||||
|
||||
&.is-replying
|
||||
box-shadow: inset 5px 0 0 $color-primary
|
||||
box-shadow: -5px 0 0 $color-primary
|
||||
@extend .pl-3
|
||||
|
||||
&.is-replying+.comment-reply-container
|
||||
box-shadow: inset 5px 0 0 $color-primary
|
||||
box-shadow: -5px 0 0 $color-primary
|
||||
margin-left: 0
|
||||
padding-left: 55px
|
||||
|
||||
@ -314,9 +315,6 @@ $comments-width-max: 710px
|
||||
color: $color-success
|
||||
|
||||
.comment-reply
|
||||
&-container
|
||||
background-color: $color-background
|
||||
|
||||
/* Little gravatar icon on the left */
|
||||
&-avatar
|
||||
img
|
||||
@ -333,7 +331,7 @@ $comments-width-max: 710px
|
||||
width: 100%
|
||||
|
||||
&-field
|
||||
background-color: $color-background-dark
|
||||
background-color: $color-background-light
|
||||
border-radius: 3px
|
||||
box-shadow: inset 0 0 2px 0 rgba(darken($color-background-dark, 20%), .5)
|
||||
display: flex
|
||||
@ -342,6 +340,7 @@ $comments-width-max: 710px
|
||||
|
||||
textarea
|
||||
+node-details-description
|
||||
background-color: $color-background-light
|
||||
border-bottom-right-radius: 0
|
||||
border-top-right-radius: 0
|
||||
border: none
|
||||
@ -376,7 +375,6 @@ $comments-width-max: 710px
|
||||
|
||||
&.filled
|
||||
textarea
|
||||
background-color: $color-background-light
|
||||
border-bottom: thin solid $color-background
|
||||
|
||||
&:focus
|
||||
@ -453,12 +451,17 @@ $comments-width-max: 710px
|
||||
transition: background-color 150ms ease-in-out, color 150ms ease-in-out
|
||||
width: 100px
|
||||
|
||||
// The actual button for submitting the comment.
|
||||
button.comment-action-submit
|
||||
align-items: center
|
||||
background: transparent
|
||||
border: none
|
||||
border-top-left-radius: 0
|
||||
border-bottom-left-radius: 0
|
||||
color: $color-success
|
||||
cursor: pointer
|
||||
display: flex
|
||||
justify-content: center
|
||||
flex-direction: column
|
||||
height: 100%
|
||||
position: relative
|
||||
@ -466,8 +469,12 @@ $comments-width-max: 710px
|
||||
white-space: nowrap
|
||||
width: 100%
|
||||
|
||||
&:hover
|
||||
background: rgba($color-success, .1)
|
||||
|
||||
&:focus
|
||||
background: lighten($color-success, 10%)
|
||||
color: $white
|
||||
|
||||
&.submitting
|
||||
color: $color-info
|
||||
|
@ -12,9 +12,10 @@ $color-background-active-dark: hsl(hue($color-background-active), 50%, 50%) !def
|
||||
$font-body: 'Roboto' !default
|
||||
$font-headings: 'Lato' !default
|
||||
$font-size: 14px !default
|
||||
$font-size-xs: .75rem
|
||||
$font-size-xxs: .65rem
|
||||
|
||||
$color-text: #4d4e53 !default
|
||||
|
||||
$color-text-dark: $color-text !default
|
||||
$color-text-dark-primary: #646469 !default
|
||||
$color-text-dark-secondary: #9E9FA2 !default
|
||||
@ -25,10 +26,11 @@ $color-text-light-primary: rgba($color-text-light, .87) !default
|
||||
$color-text-light-secondary: rgba($color-text-light, .54) !default
|
||||
$color-text-light-hint: rgba($color-text-light, .38) !default
|
||||
|
||||
$color-primary: #68B3C8 !default
|
||||
$color-primary: #009eff !default
|
||||
$color-primary-light: hsl(hue($color-primary), 30%, 90%) !default
|
||||
$color-primary-dark: hsl(hue($color-primary), 80%, 30%) !default
|
||||
$color-primary-accent: hsl(hue($color-primary), 100%, 50%) !default
|
||||
$primary-accent: #0bd
|
||||
|
||||
$color-secondary: #f42942 !default
|
||||
$color-secondary-light: hsl(hue($color-secondary), 30%, 90%) !default
|
||||
@ -96,16 +98,17 @@ $screen-xs-max: $screen-sm-min - 1 !default
|
||||
$screen-sm-max: $screen-md-min - 1 !default
|
||||
$screen-md-max: $screen-lg-min - 1 !default
|
||||
|
||||
$sidebar-width: 50px !default
|
||||
$sidebar-width: 40px !default
|
||||
|
||||
/* Project specifics */
|
||||
$project_nav-width: 250px !default
|
||||
$project-sidebar-width: 50px !default
|
||||
$project_header-height: 50px !default
|
||||
$project_footer-height: 30px !default
|
||||
|
||||
$navbar-height: 50px !default
|
||||
$navbar-backdrop-height: 600px !default
|
||||
$project_nav-width-xl: $project_nav-width * 1.4 !default
|
||||
$project_nav-width-lg: $project_nav-width * 1.2 !default
|
||||
$project_nav-width-md: $project_nav-width
|
||||
$project_nav-width-sm: $project_nav-width * 0.8 !default
|
||||
$project_nav-width-xs: 100% !default
|
||||
$project-sidebar-width: 40px !default
|
||||
$project_header-height: 40px !default
|
||||
|
||||
$node-type-asset_image: #e87d86 !default
|
||||
$node-type-asset_file: #CC91C7 !default
|
||||
@ -125,3 +128,39 @@ $z-index-base: 13 !default
|
||||
|
||||
@media (min-width: $screen-lg-min)
|
||||
width: 1270px
|
||||
|
||||
|
||||
// Bootstrap overrides.
|
||||
$enable-caret: false
|
||||
|
||||
$border-radius: .2rem
|
||||
$btn-border-radius: $border-radius
|
||||
|
||||
$primary: $color-primary
|
||||
|
||||
$body-bg: $white
|
||||
$body-color: $color-text
|
||||
|
||||
$color-background-nav: #fff
|
||||
$link-color: $primary
|
||||
|
||||
$font-family-sans-serif: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, Helvetica, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"
|
||||
$font-size-base: .9rem
|
||||
|
||||
$dropdown-border-width: 0
|
||||
$dropdown-box-shadow: 0 10px 25px rgba($black, .1)
|
||||
$dropdown-padding-y: 0
|
||||
$dropdown-item-padding-y: .4rem
|
||||
|
||||
// Tooltips.
|
||||
$tooltip-font-size: 0.83rem
|
||||
$tooltip-max-width: auto
|
||||
$tooltip-opacity: 1
|
||||
|
||||
$nav-link-height: 37px
|
||||
$navbar-padding-x: 0
|
||||
$navbar-padding-y: 0
|
||||
|
||||
$btn-padding-y-sm: 0.1rem
|
||||
|
||||
$grid-breakpoints: (xs: 0,sm: 576px,md: 768px,lg: 1060px,xl: 1500px, xxl: 1800px)
|
||||
|
@ -60,14 +60,13 @@
|
||||
|
||||
#node-overlay
|
||||
#error-container
|
||||
position: fixed
|
||||
top: $navbar-height
|
||||
align-items: flex-start
|
||||
position: fixed
|
||||
top: $nav-link-height
|
||||
|
||||
#error-box
|
||||
box-shadow: 0 0 25px rgba(black, .1), 0 0 50px rgba(black, .1)
|
||||
width: auto
|
||||
border-top-left-radius: 0
|
||||
border-top-right-radius: 0
|
||||
box-shadow: 0 0 25px rgba(black, .1), 0 0 50px rgba(black, .1)
|
||||
position: relative
|
||||
width: 100%
|
||||
|
@ -9,7 +9,6 @@
|
||||
color: $color-primary
|
||||
cursor: pointer
|
||||
float: right
|
||||
font-family: $font-body
|
||||
height: initial
|
||||
margin: 0
|
||||
padding: 8px 10px 0 10px
|
||||
@ -25,13 +24,16 @@
|
||||
color: $color-secondary
|
||||
|
||||
#notifications-toggle
|
||||
color: $color-text
|
||||
cursor: pointer
|
||||
font-size: 1.5em
|
||||
position: relative
|
||||
user-select: none
|
||||
|
||||
> i:before
|
||||
content: '\e815'
|
||||
font-size: 1.3em
|
||||
position: relative
|
||||
top: 2px
|
||||
|
||||
&.has-notifications
|
||||
> i:before
|
||||
@ -46,10 +48,10 @@
|
||||
border-color: transparent transparent $color-background transparent
|
||||
border-style: solid
|
||||
border-width: 0 8px 8px 8px
|
||||
bottom: -15px
|
||||
bottom: -10px
|
||||
height: 0
|
||||
position: absolute
|
||||
right: 22px
|
||||
right: 7px
|
||||
visibility: hidden
|
||||
width: 0
|
||||
|
||||
|
@ -1,20 +1,4 @@
|
||||
body.organizations
|
||||
ul#sub-nav-tabs__list
|
||||
align-items: center
|
||||
display: flex
|
||||
|
||||
li.result
|
||||
padding: 10px 20px
|
||||
li.create
|
||||
margin-left: auto
|
||||
|
||||
|
||||
.dashboard-secondary
|
||||
.box
|
||||
+container-box
|
||||
padding: 10px 20px
|
||||
margin: 0
|
||||
|
||||
#item-details
|
||||
.organization
|
||||
label
|
||||
|
@ -409,7 +409,6 @@ a.page-card-cta
|
||||
display: block
|
||||
+position-center-translate
|
||||
|
||||
|
||||
+media-xs
|
||||
display: none
|
||||
+media-sm
|
||||
@ -419,9 +418,6 @@ a.page-card-cta
|
||||
+media-lg
|
||||
width: 100%
|
||||
|
||||
.services.navbar-backdrop-overlay
|
||||
background: rgba(black, .5)
|
||||
|
||||
.services
|
||||
.page-card-side
|
||||
max-width: 500px
|
||||
|
@ -1,22 +1,16 @@
|
||||
|
||||
.dashboard-container
|
||||
section#home,
|
||||
section#projects
|
||||
background-color: $color-background
|
||||
border-bottom-left-radius: 3px
|
||||
border-bottom-right-radius: 3px
|
||||
|
||||
nav#sub-nav-tabs.home,
|
||||
nav#sub-nav-tabs.projects
|
||||
background-color: white
|
||||
border-bottom: thin solid $color-background-dark
|
||||
|
||||
li.nav-tabs__list-tab
|
||||
padding: 15px 20px 10px 20px
|
||||
|
||||
section#home
|
||||
background-color: $color-background-dark
|
||||
|
||||
nav.nav-tabs__tab
|
||||
display: none
|
||||
background-color: $color-background-light
|
||||
@ -287,9 +281,8 @@
|
||||
flex-direction: column
|
||||
|
||||
.title
|
||||
font-size: 1.2em
|
||||
padding-bottom: 2px
|
||||
color: $color-text-dark-primary
|
||||
padding-bottom: 2px
|
||||
|
||||
ul.meta
|
||||
font-size: .9em
|
||||
|
@ -92,19 +92,6 @@ ul.sharing-users-list
|
||||
&:hover
|
||||
color: lighten($color-danger, 10%)
|
||||
|
||||
.sharing-users-intro,
|
||||
.sharing-users-info
|
||||
h4
|
||||
font-family: $font-body
|
||||
|
||||
.sharing-users-info
|
||||
padding-left: 15px
|
||||
border-left: thin solid $color-text-dark-hint
|
||||
|
||||
p
|
||||
font:
|
||||
size: 1.1em
|
||||
weight: 300
|
||||
|
||||
.sharing-users-search
|
||||
.disabled
|
||||
@ -162,24 +149,26 @@ ul.list-generic
|
||||
list-style: none
|
||||
|
||||
> li
|
||||
padding: 5px 0
|
||||
display: flex
|
||||
align-items: center
|
||||
border-top: thin solid $color-background
|
||||
display: flex
|
||||
padding: 5px 0
|
||||
|
||||
&:first-child
|
||||
border-top: none
|
||||
|
||||
&:hover .item a
|
||||
color: $color-primary
|
||||
color: $primary
|
||||
|
||||
a
|
||||
flex: 1
|
||||
|
||||
&.active
|
||||
color: $primary !important
|
||||
font-weight: bold
|
||||
|
||||
.actions
|
||||
margin-left: auto
|
||||
.btn
|
||||
font-size: .7em
|
||||
|
||||
span
|
||||
color: $color-text-dark-secondary
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -16,7 +16,7 @@ $search-hit-width_grid: 100px
|
||||
.search-hit-name
|
||||
font-weight: 400
|
||||
padding-top: 8px
|
||||
color: $color-primary-dark
|
||||
color: $primary
|
||||
|
||||
.search-hit
|
||||
padding: 0
|
||||
@ -29,14 +29,13 @@ $search-hit-width_grid: 100px
|
||||
font:
|
||||
size: .9em
|
||||
weight: 400
|
||||
family: $font-body
|
||||
style: initial
|
||||
width: 100%
|
||||
+text-overflow-ellipsis
|
||||
+clearfix
|
||||
|
||||
& em
|
||||
color: $color-primary-dark
|
||||
color: $primary
|
||||
font-style: normal
|
||||
|
||||
&:hover
|
||||
@ -71,7 +70,7 @@ $search-hit-width_grid: 100px
|
||||
min-width: 350px
|
||||
border-bottom-left-radius: 3px
|
||||
border-bottom-right-radius: 3px
|
||||
border-top: 3px solid lighten($color-primary, 5%)
|
||||
border-top: 3px solid lighten($primary, 5%)
|
||||
overflow: hidden
|
||||
|
||||
.tt-suggestion
|
||||
@ -93,235 +92,68 @@ $search-hit-width_grid: 100px
|
||||
&.tt-cursor:hover .search-hit
|
||||
background-color: lighten($color-background, 5%)
|
||||
|
||||
#search-container
|
||||
display: flex
|
||||
min-height: 600px
|
||||
background-color: white
|
||||
.search-list
|
||||
width: 50%
|
||||
|
||||
+media-lg
|
||||
padding-left: 0
|
||||
padding-right: 0
|
||||
.embed-responsive
|
||||
width: 100px
|
||||
min-width: 100px
|
||||
|
||||
#search-sidebar
|
||||
width: 20%
|
||||
background-color: $color-background-light
|
||||
.card-deck.card-deck-vertical
|
||||
.card
|
||||
flex-wrap: initial
|
||||
|
||||
+media-lg
|
||||
border-top-left-radius: 3px
|
||||
.search-settings
|
||||
width: 30%
|
||||
|
||||
input.search-field
|
||||
background-color: $color-background-nav-dark
|
||||
font-size: 1.1em
|
||||
color: white
|
||||
margin-bottom: 10px
|
||||
.card-deck.card-deck-vertical
|
||||
.card .embed-responsive
|
||||
max-width: 80px
|
||||
|
||||
input.search-field
|
||||
border: none
|
||||
border-bottom: 2px solid rgba($primary, .2)
|
||||
border-radius: 0
|
||||
width: 100%
|
||||
transition: border 100ms ease-in-out
|
||||
|
||||
&::placeholder
|
||||
color: $color-text-dark-secondary
|
||||
&:placeholder-shown
|
||||
border-bottom-color: $primary
|
||||
|
||||
&:focus
|
||||
outline: none
|
||||
border: none
|
||||
border-bottom: 2px solid rgba($color-primary, .2)
|
||||
border-radius: 0
|
||||
width: 100%
|
||||
padding: 5px 15px
|
||||
height: 50px
|
||||
transition: border 100ms ease-in-out
|
||||
border-bottom: 2px solid lighten($primary, 5%)
|
||||
|
||||
&::placeholder
|
||||
color: $color-text-dark-secondary
|
||||
&:placeholder-shown
|
||||
border-bottom-color: $color-primary
|
||||
.search-details
|
||||
width: 70%
|
||||
|
||||
&:focus
|
||||
outline: none
|
||||
border: none
|
||||
border-bottom: 2px solid lighten($color-primary, 5%)
|
||||
.container-fluid .col-md-8
|
||||
flex: 1
|
||||
max-width: 100%
|
||||
width: 100%
|
||||
|
||||
.search-list-filters
|
||||
padding:
|
||||
left: 10px
|
||||
right: 10px
|
||||
#search-details
|
||||
position: relative
|
||||
|
||||
.panel.panel-default
|
||||
margin-bottom: 10px
|
||||
border-radius: 3px
|
||||
border: none
|
||||
background-color: white
|
||||
box-shadow: 1px 1px 0 rgba(black, .1)
|
||||
|
||||
a
|
||||
text-decoration: none
|
||||
|
||||
.toggleRefine
|
||||
display: block
|
||||
padding-left: 7px
|
||||
color: $color-text-dark
|
||||
text-transform: capitalize
|
||||
|
||||
&:hover
|
||||
text-decoration: none
|
||||
color: $color-primary
|
||||
|
||||
&.refined
|
||||
color: $color-primary
|
||||
|
||||
&:hover
|
||||
color: $color-danger
|
||||
|
||||
span
|
||||
&:before
|
||||
/* x icon */
|
||||
content: '\e84b'
|
||||
font-family: 'pillar-font'
|
||||
span
|
||||
&:before
|
||||
/* circle with dot */
|
||||
content: '\e82f'
|
||||
font-family: 'pillar-font'
|
||||
position: relative
|
||||
left: -7px
|
||||
font-size: .9em
|
||||
|
||||
span
|
||||
&:before
|
||||
/* empty circle */
|
||||
content: '\e82c'
|
||||
font-family: 'pillar-font'
|
||||
position: relative
|
||||
left: -7px
|
||||
font-size: .9em
|
||||
.facet_count
|
||||
color: $color-text-dark-secondary
|
||||
|
||||
|
||||
.panel-title, .panel-heading
|
||||
color: $color-text-dark-secondary
|
||||
font:
|
||||
size: 1em
|
||||
weight: 500
|
||||
|
||||
.panel-body
|
||||
padding-top: 0
|
||||
|
||||
.panel-title
|
||||
position: relative
|
||||
&:after
|
||||
content: '\e83b'
|
||||
font-family: 'pillar-font'
|
||||
position: absolute
|
||||
right: 0
|
||||
color: $color-text-dark-primary
|
||||
|
||||
.collapsed
|
||||
.panel-title:after
|
||||
content: '\e838'
|
||||
|
||||
|
||||
.search-list-stats
|
||||
color: $color-text-dark-hint
|
||||
padding: 10px 15px 0 15px
|
||||
text-align: center
|
||||
font-size: .9em
|
||||
+clearfix
|
||||
|
||||
#pagination
|
||||
ul.search-pagination
|
||||
text-align: center
|
||||
list-style-type: none
|
||||
margin: 0
|
||||
padding: 0
|
||||
width: 100%
|
||||
display: flex
|
||||
+clearfix
|
||||
|
||||
li
|
||||
display: inline-block
|
||||
margin: 5px auto
|
||||
|
||||
&:last-child
|
||||
border-color: transparent
|
||||
|
||||
a
|
||||
font-weight: 500
|
||||
padding: 5px 4px
|
||||
color: $color-text-dark-secondary
|
||||
|
||||
&:hover
|
||||
color: $color-text-dark-primary
|
||||
|
||||
&.disabled
|
||||
opacity: .6
|
||||
|
||||
&.active a
|
||||
color: $color-text-dark-primary
|
||||
font-weight: bold
|
||||
|
||||
#search-list
|
||||
width: 40%
|
||||
height: 100%
|
||||
padding: 0
|
||||
position: relative
|
||||
overflow-x: hidden
|
||||
#search-hit-container
|
||||
position: absolute // for scrollbars
|
||||
overflow-y: auto
|
||||
|
||||
#hits
|
||||
#error_container
|
||||
position: relative
|
||||
width: 100%
|
||||
background: white
|
||||
padding: 20px
|
||||
|
||||
#no-hits
|
||||
padding: 10px 15px
|
||||
color: $color-text-dark-secondary
|
||||
|
||||
.search-hit
|
||||
#search-loading
|
||||
visibility: hidden
|
||||
background-color: transparent
|
||||
font:
|
||||
size: 1.5em
|
||||
weight: 600
|
||||
position: absolute
|
||||
top: 0
|
||||
left: 0
|
||||
right: 0
|
||||
bottom: 0
|
||||
z-index: $z-index-base + 5
|
||||
opacity: 0
|
||||
cursor: default
|
||||
transition: opacity 50ms ease-in-out
|
||||
&.active
|
||||
visibility: visible
|
||||
opacity: 1
|
||||
|
||||
.spinner
|
||||
color: $color-background-nav
|
||||
background-color: white
|
||||
padding: 0
|
||||
width: 20px
|
||||
height: 20px
|
||||
border-radius: 50%
|
||||
position: absolute
|
||||
top: 7px
|
||||
right: 10px
|
||||
span
|
||||
padding: 5px
|
||||
+pulse
|
||||
|
||||
#search-details
|
||||
position: relative
|
||||
width: 40%
|
||||
border-left: 2px solid darken(white, 3%)
|
||||
|
||||
#search-hit-container
|
||||
position: absolute // for scrollbars
|
||||
width: 100%
|
||||
overflow-y: auto
|
||||
|
||||
#error_container
|
||||
position: relative
|
||||
background: white
|
||||
padding: 20px
|
||||
|
||||
#search-error
|
||||
display: none
|
||||
margin: 20px auto
|
||||
color: $color-danger
|
||||
text-align: center
|
||||
#search-error
|
||||
display: none
|
||||
margin: 20px auto
|
||||
color: $color-danger
|
||||
text-align: center
|
||||
|
||||
#search-container
|
||||
#node-container
|
||||
width: 100%
|
||||
max-width: 100%
|
||||
@ -416,9 +248,7 @@ $search-hit-width_grid: 100px
|
||||
|
||||
&.texture
|
||||
.texture-title
|
||||
font:
|
||||
size: 2em
|
||||
family: $font-body
|
||||
font-size: 2em
|
||||
padding: 15px 10px 10px 15px
|
||||
.node-row
|
||||
background: white
|
||||
@ -476,215 +306,88 @@ $search-hit-width_grid: 100px
|
||||
button
|
||||
width: 100%
|
||||
|
||||
.search-hit
|
||||
float: left
|
||||
box-shadow: none
|
||||
border: thin solid transparent
|
||||
border-top-color: darken(white, 8%)
|
||||
border-left: 3px solid transparent
|
||||
.search-project
|
||||
li.project
|
||||
display: none
|
||||
|
||||
color: $color-background-nav
|
||||
|
||||
width: 100%
|
||||
position: relative
|
||||
margin: 0
|
||||
padding: 7px 10px 7px 10px
|
||||
+clearfix
|
||||
|
||||
&:first-child
|
||||
border: thin solid transparent
|
||||
border-left: 3px solid transparent
|
||||
|
||||
&:hover
|
||||
opacity: 1
|
||||
text-decoration: none
|
||||
cursor: default
|
||||
color: darken($color-primary, 20%)
|
||||
background-color: $color-background-light
|
||||
|
||||
& .search-hit-name i
|
||||
color: darken($color-primary, 20%)
|
||||
|
||||
& .search-hit-thumbnail
|
||||
& .search-hit-thumbnail-icon
|
||||
transform: translate(-50%, -50%) scale(1.1)
|
||||
|
||||
.search-hit-name
|
||||
text-decoration: none
|
||||
&:hover
|
||||
color: darken($color-primary, 10%)
|
||||
|
||||
.search-hit-thumbnail
|
||||
cursor: pointer
|
||||
|
||||
.search-hit-thumbnail-icon
|
||||
transform: translate(-50%, -50%) scale(1)
|
||||
|
||||
&:active
|
||||
background-color: rgba($color-background, .5)
|
||||
opacity: .8
|
||||
color: $color-primary
|
||||
& .search-hit-name i
|
||||
color: $color-primary
|
||||
|
||||
&:focus
|
||||
border-color: rgba($color-primary, .2)
|
||||
|
||||
/* Class that gets added when we click on the item */
|
||||
&.active
|
||||
background-color: lighten($color-background, 2%)
|
||||
border-left: 3px solid $color-primary
|
||||
|
||||
.search-hit-name
|
||||
color: darken($color-primary, 10%)
|
||||
|
||||
.search-hit-meta
|
||||
span.when
|
||||
display: none
|
||||
span.context
|
||||
display: inline-block
|
||||
|
||||
.search-hit-thumbnail
|
||||
position: relative
|
||||
float: left
|
||||
min-width: $search-hit-width_list * 1.49
|
||||
max-width: $search-hit-width_list * 1.49
|
||||
height: $search-hit-width_list
|
||||
border-radius: 3px
|
||||
background: $color-background
|
||||
margin-right: 12px
|
||||
text-align: center
|
||||
overflow: hidden
|
||||
+media-xs
|
||||
display: none
|
||||
+media-sm
|
||||
min-width: $search-hit-width_list
|
||||
max-width: $search-hit-width_list
|
||||
|
||||
img
|
||||
height: $search-hit-width_list
|
||||
width: auto
|
||||
|
||||
|
||||
.pi-video:before, .pi-file:before,
|
||||
.pi-group:before
|
||||
font-family: 'pillar-font'
|
||||
.pi-video:before
|
||||
content: '\e81d'
|
||||
.pi-file:before
|
||||
content: '\e825'
|
||||
.pi-group:before
|
||||
content: '\e80d'
|
||||
|
||||
.search-hit-thumbnail-icon
|
||||
position: absolute
|
||||
top: 50%
|
||||
left: 50%
|
||||
transform: translate(-50%, -50%)
|
||||
color: white
|
||||
font-size: 1.2em
|
||||
transition: none
|
||||
color: $color-text-dark-secondary
|
||||
|
||||
.dark
|
||||
text-shadow: none
|
||||
font-size: 1.3em
|
||||
|
||||
.search-hit-name
|
||||
position: relative
|
||||
font-size: 1.1em
|
||||
color: $color-text-dark-primary
|
||||
background-color: initial
|
||||
width: initial
|
||||
max-width: initial
|
||||
+text-overflow-ellipsis
|
||||
padding-top: 5px
|
||||
#search-sidebar
|
||||
.toggleRefine
|
||||
display: block
|
||||
padding-left: 7px
|
||||
color: $color-text-dark
|
||||
text-transform: capitalize
|
||||
|
||||
&:hover
|
||||
cursor: pointer
|
||||
text-decoration: underline
|
||||
text-decoration: none
|
||||
color: $primary
|
||||
|
||||
em
|
||||
color: darken($color-primary, 15%)
|
||||
font-style: normal
|
||||
&.refined
|
||||
color: $primary
|
||||
|
||||
.search-hit-ribbon
|
||||
+ribbon
|
||||
right: -30px
|
||||
top: 5px
|
||||
&:hover
|
||||
color: $color-danger
|
||||
|
||||
span
|
||||
&:before
|
||||
/* x icon */
|
||||
content: '\e84b'
|
||||
font-family: 'pillar-font'
|
||||
span
|
||||
&:before
|
||||
/* circle with dot */
|
||||
content: '\e82f'
|
||||
font-family: 'pillar-font'
|
||||
position: relative
|
||||
left: -7px
|
||||
font-size: .9em
|
||||
|
||||
span
|
||||
font-size: 60%
|
||||
margin: 1px 0
|
||||
padding: 2px 35px
|
||||
&:before
|
||||
/* empty circle */
|
||||
content: '\e82c'
|
||||
font-family: 'pillar-font'
|
||||
position: relative
|
||||
left: -7px
|
||||
font-size: .9em
|
||||
|
||||
.search-hit-meta
|
||||
position: relative
|
||||
.search-list-stats
|
||||
color: $color-text-dark-hint
|
||||
padding: 10px 15px 0 15px
|
||||
text-align: center
|
||||
font-size: .9em
|
||||
color: $color-text-dark-secondary
|
||||
background-color: initial
|
||||
padding: 3px 0 0 0
|
||||
text-decoration: none
|
||||
+text-overflow-ellipsis
|
||||
+clearfix
|
||||
|
||||
span
|
||||
&.project
|
||||
color: $color-text-dark-secondary
|
||||
margin-right: 3px
|
||||
&.updated
|
||||
color: $color-text-dark-hint
|
||||
&.status
|
||||
font-size: .8em
|
||||
color: $color-text-dark-secondary
|
||||
border: thin solid $color-text-dark-hint
|
||||
padding: 3px 8px
|
||||
text-transform: uppercase
|
||||
border-radius: 3px
|
||||
margin-right: 5px
|
||||
&.media, &.node_type
|
||||
color: $color-text-dark-secondary
|
||||
text-transform: capitalize
|
||||
margin: 0 3px
|
||||
.search-pagination
|
||||
text-align: center
|
||||
list-style-type: none
|
||||
margin: 0
|
||||
padding: 0
|
||||
width: 100%
|
||||
display: flex
|
||||
+clearfix
|
||||
|
||||
&.when
|
||||
margin: 0 3px
|
||||
float: right
|
||||
display: block
|
||||
+media-lg
|
||||
display: block
|
||||
+media-md
|
||||
display: block
|
||||
+media-sm
|
||||
display: none
|
||||
+media-xs
|
||||
display: none
|
||||
li
|
||||
display: inline-block
|
||||
margin: 5px auto
|
||||
|
||||
&.context
|
||||
margin: 0
|
||||
float: right
|
||||
display: none
|
||||
&:last-child
|
||||
border-color: transparent
|
||||
|
||||
&:hover
|
||||
cursor: pointer
|
||||
.search-hit-name-user
|
||||
color: $color-primary
|
||||
|
||||
&.users
|
||||
em
|
||||
font-style: normal
|
||||
color: $color-primary
|
||||
|
||||
.search-hit-name
|
||||
font-size: 1.2em
|
||||
|
||||
small
|
||||
margin-left: 5px
|
||||
color: $color-text-dark-secondary
|
||||
|
||||
.search-hit-roles
|
||||
font-size: .9em
|
||||
a
|
||||
font-weight: 500
|
||||
padding: 5px 4px
|
||||
color: $color-text-dark-secondary
|
||||
margin-left: 15px
|
||||
|
||||
&:hover
|
||||
color: $color-text-dark-primary
|
||||
|
||||
&.disabled
|
||||
opacity: .6
|
||||
|
||||
&.active a
|
||||
color: $color-text-dark-primary
|
||||
font-weight: bold
|
||||
|
||||
|
||||
.view-grid
|
||||
display: flex
|
||||
@ -706,13 +409,13 @@ $search-hit-width_grid: 100px
|
||||
transition: border-color 150ms ease-in-out
|
||||
|
||||
&.active
|
||||
background-color: $color-primary
|
||||
border-color: $color-primary
|
||||
background-color: $primary
|
||||
border-color: $primary
|
||||
|
||||
.search-hit-name
|
||||
font-weight: 500
|
||||
color: white
|
||||
background-color: $color-primary
|
||||
background-color: $primary
|
||||
|
||||
.search-hit-name
|
||||
font-size: .9em
|
||||
@ -776,5 +479,5 @@ $search-hit-width_grid: 100px
|
||||
|
||||
&.active
|
||||
color: white
|
||||
background-color: $color-primary
|
||||
background-color: $primary
|
||||
border-color: transparent
|
||||
|
@ -67,138 +67,6 @@
|
||||
&:hover
|
||||
background-color: lighten($provider-color-google, 7%)
|
||||
|
||||
#settings
|
||||
+media-xs
|
||||
flex-direction: column
|
||||
|
||||
align-items: stretch
|
||||
display: flex
|
||||
margin: 25px auto
|
||||
|
||||
#settings-sidebar
|
||||
+media-xs
|
||||
width: 100%
|
||||
|
||||
+container-box
|
||||
background-color: $color-background-light
|
||||
color: $color-text
|
||||
margin-right: 15px
|
||||
width: 30%
|
||||
|
||||
.settings-content
|
||||
padding: 0
|
||||
|
||||
ul
|
||||
list-style: none
|
||||
margin: 0
|
||||
padding: 0
|
||||
|
||||
a
|
||||
&:hover
|
||||
text-decoration: none
|
||||
|
||||
li
|
||||
background-color: lighten($color-background, 5%)
|
||||
|
||||
li
|
||||
border-bottom: thin solid $color-background
|
||||
border-left: thick solid transparent
|
||||
margin: 0
|
||||
padding: 25px
|
||||
transition: all 100ms ease-in-out
|
||||
|
||||
i
|
||||
font-size: 1.1em
|
||||
padding-right: 15px
|
||||
|
||||
.active
|
||||
li
|
||||
background-color: lighten($color-background, 5%)
|
||||
border-left: thick solid $color-info
|
||||
|
||||
|
||||
#settings-container
|
||||
+media-xs
|
||||
width: 100%
|
||||
|
||||
+container-box
|
||||
background-color: $color-background-light
|
||||
width: 70%
|
||||
|
||||
.settings-header
|
||||
background-color: $color-background
|
||||
border-top-left-radius: 3px
|
||||
border-top-right-radius: 3px
|
||||
|
||||
.settings-title
|
||||
font:
|
||||
size: 1.5em
|
||||
weight: 300
|
||||
padding: 10px 15px 10px 25px
|
||||
|
||||
.settings-content
|
||||
padding: 25px
|
||||
|
||||
.settings-billing-info
|
||||
font-size: 1.2em
|
||||
|
||||
.subscription-active
|
||||
color: $color-success
|
||||
padding-bottom: 20px
|
||||
.subscription-demo
|
||||
color: $color-info
|
||||
margin-top: 0
|
||||
.subscription-missing
|
||||
color: $color-danger
|
||||
margin-top: 0
|
||||
|
||||
.button-submit
|
||||
clear: both
|
||||
display: block
|
||||
min-width: 200px
|
||||
margin: 0 auto
|
||||
+button($color-primary, 3px, true)
|
||||
|
||||
|
||||
#settings-container
|
||||
#settings-form
|
||||
width: 100%
|
||||
|
||||
|
||||
.settings-form
|
||||
align-items: center
|
||||
display: flex
|
||||
justify-content: center
|
||||
|
||||
.left, .right
|
||||
padding: 25px 0
|
||||
|
||||
.left
|
||||
width: 60%
|
||||
float: left
|
||||
|
||||
.right
|
||||
width: 40%
|
||||
float: right
|
||||
text-align: center
|
||||
|
||||
label
|
||||
color: $color-text
|
||||
display: block
|
||||
|
||||
.settings-avatar
|
||||
img
|
||||
border-radius: 3px
|
||||
|
||||
span
|
||||
display: block
|
||||
padding: 15px 0
|
||||
font:
|
||||
size: .9em
|
||||
|
||||
.settings-password
|
||||
color: $color-text-dark-primary
|
||||
|
||||
|
||||
#user-edit-container
|
||||
padding: 15px
|
||||
|
@ -26,7 +26,6 @@
|
||||
display: inline-flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
font-family: $font-body
|
||||
padding: 5px 12px
|
||||
border-radius: $roundness
|
||||
|
||||
@ -83,6 +82,15 @@
|
||||
text-shadow: none
|
||||
|
||||
|
||||
=disabled-stripes
|
||||
color: $color-text-dark
|
||||
cursor: not-allowed
|
||||
background: repeating-linear-gradient(-45deg, lighten($color-text-dark-hint, 15%), lighten($color-text-dark-hint, 15%) 10px, lighten($color-text-dark-hint, 5%) 10px, lighten($color-text-dark-hint, 5%) 20px)
|
||||
border-color: darken($color-text-dark-hint, 5%)
|
||||
pointer-events: none
|
||||
opacity: .6
|
||||
|
||||
|
||||
@mixin overlay($from-color, $from-percentage, $to-color, $to-percentage)
|
||||
position: absolute
|
||||
top: 0
|
||||
@ -122,24 +130,16 @@
|
||||
transform: translate(-50%, -50%)
|
||||
|
||||
=input-generic
|
||||
padding: 5px 5px 5px 0
|
||||
color: $color-text-dark
|
||||
box-shadow: none
|
||||
font-family: $font-body
|
||||
border: thin solid transparent
|
||||
border-radius: 0
|
||||
border-bottom-color: $color-background-dark
|
||||
background-color: transparent
|
||||
transition: border-color 150ms ease-in-out, box-shadow 150ms ease-in-out
|
||||
|
||||
&:hover
|
||||
border-bottom-color: $color-background
|
||||
|
||||
&:focus
|
||||
outline: 0
|
||||
border: thin solid transparent
|
||||
border-bottom-color: $color-primary
|
||||
box-shadow: 0 1px 0 0 $color-primary
|
||||
border-color: $primary
|
||||
box-shadow: none
|
||||
|
||||
=label-generic
|
||||
color: $color-text-dark-primary
|
||||
@ -170,17 +170,25 @@
|
||||
/* Small but wide: phablets, iPads
|
||||
** Menu is collapsed, columns stack, no brand */
|
||||
=media-sm
|
||||
@media (min-width: #{$screen-tablet}) and (max-width: #{$screen-desktop - 1px})
|
||||
@include media-breakpoint-up(sm)
|
||||
@content
|
||||
|
||||
/* Tablets portrait.
|
||||
** Menu is expanded, but columns stack, brand is shown */
|
||||
=media-md
|
||||
@media (min-width: #{$screen-desktop})
|
||||
@include media-breakpoint-up(md)
|
||||
@content
|
||||
|
||||
=media-lg
|
||||
@media (min-width: #{$screen-lg-desktop})
|
||||
@include media-breakpoint-up(lg)
|
||||
@content
|
||||
|
||||
=media-xl
|
||||
@include media-breakpoint-up(xl)
|
||||
@content
|
||||
|
||||
=media-xxl
|
||||
@include media-breakpoint-up(xxl)
|
||||
@content
|
||||
|
||||
=media-print
|
||||
@ -352,17 +360,15 @@
|
||||
|
||||
=node-details-description
|
||||
+clearfix
|
||||
color: darken($color-text-dark, 5%)
|
||||
font:
|
||||
family: $font-body
|
||||
weight: 300
|
||||
size: 1.2em
|
||||
|
||||
color: $color-text
|
||||
font-size: 1.25em
|
||||
word-break: break-word
|
||||
|
||||
+media-xs
|
||||
font-size: 1.1em
|
||||
|
||||
/* Style links without a class. Usually regular
|
||||
* links in a comment or node description. */
|
||||
a:not([class])
|
||||
color: $color-text-dark-primary
|
||||
text-decoration: underline
|
||||
@ -375,11 +381,6 @@
|
||||
line-height: 1.5em
|
||||
word-wrap: break-word
|
||||
|
||||
h1, h2, h3, h4, h5, h6
|
||||
padding:
|
||||
top: 20px
|
||||
right: 20px
|
||||
|
||||
blockquote
|
||||
background-color: lighten($color-background-light, 5%)
|
||||
box-shadow: inset 5px 0 0 $color-background
|
||||
@ -400,10 +401,10 @@
|
||||
img,
|
||||
p img,
|
||||
ul li img
|
||||
@extend .d-block
|
||||
@extend .mx-auto
|
||||
@extend .my-3
|
||||
max-width: 100%
|
||||
padding:
|
||||
bottom: 25px
|
||||
top: 25px
|
||||
|
||||
&.emoji
|
||||
display: inline-block
|
||||
@ -416,25 +417,13 @@
|
||||
font-size: 1.5em
|
||||
|
||||
/* e.g. YouTube embed */
|
||||
iframe
|
||||
height: auto
|
||||
margin: 15px auto
|
||||
iframe, video
|
||||
max-width: 100%
|
||||
min-height: 500px
|
||||
width: 100%
|
||||
@extend .mx-auto
|
||||
|
||||
+media-sm
|
||||
iframe
|
||||
min-height: 314px
|
||||
+media-xs
|
||||
iframe
|
||||
min-height: 314px
|
||||
|
||||
iframe[src^="https://www.youtube"]
|
||||
+media-xs
|
||||
iframe
|
||||
min-height: 420px
|
||||
min-height: 500px
|
||||
.embed-responsive,
|
||||
video
|
||||
@extend .my-3
|
||||
|
||||
iframe[src^="https://w.soundcloud"]
|
||||
min-height: auto
|
||||
@ -507,28 +496,24 @@
|
||||
|
||||
=ribbon
|
||||
background-color: $color-success
|
||||
cursor: default
|
||||
border: thin dashed rgba(white, .5)
|
||||
color: white
|
||||
pointer-events: none
|
||||
font-size: 70%
|
||||
overflow: hidden
|
||||
white-space: nowrap
|
||||
position: absolute
|
||||
right: -40px
|
||||
top: 10px
|
||||
-webkit-transform: rotate(45deg)
|
||||
-moz-transform: rotate(45deg)
|
||||
-ms-transform: rotate(45deg)
|
||||
-o-transform: rotate(45deg)
|
||||
transform: rotate(45deg)
|
||||
white-space: nowrap
|
||||
|
||||
span
|
||||
border: thin dashed rgba(white, .5)
|
||||
color: white
|
||||
display: block
|
||||
font-size: 70%
|
||||
margin: 1px 0
|
||||
padding: 3px 50px
|
||||
text:
|
||||
align: center
|
||||
transform: uppercase
|
||||
|
||||
.ribbon
|
||||
+ribbon
|
||||
|
||||
@mixin text-background($text-color, $background-color, $roundness, $padding)
|
||||
border-radius: $roundness
|
||||
@ -568,9 +553,7 @@
|
||||
|
||||
/* Bootstrap's img-responsive class */
|
||||
=img-responsive
|
||||
display: block
|
||||
max-width: 100%
|
||||
height: auto
|
||||
@extend .img-fluid
|
||||
|
||||
/* Set the color for a specified property
|
||||
* 1: $property: e.g. background-color
|
||||
@ -642,9 +625,7 @@
|
||||
#{$property}: $color-status-review
|
||||
|
||||
=sidebar-button-active
|
||||
background-color: $color-background-nav
|
||||
box-shadow: inset 2px 0 0 $color-primary
|
||||
color: white
|
||||
color: $primary
|
||||
|
||||
.flash-on
|
||||
background-color: lighten($color-success, 50%) !important
|
||||
@ -660,3 +641,52 @@
|
||||
transition: all 1s ease-out
|
||||
img
|
||||
transition: all 1s ease-out
|
||||
|
||||
.cursor-pointer
|
||||
cursor: pointer
|
||||
|
||||
.cursor-zoom-in
|
||||
cursor: zoom-in
|
||||
|
||||
.user-select-none
|
||||
user-select: none
|
||||
|
||||
.pointer-events-none
|
||||
pointer-events: none
|
||||
|
||||
// Bootstrap has .img-fluid, a class to limit the width of an image to 100%.
|
||||
// .imgs-fluid below is to be applied on a parent container when we can't add
|
||||
// classes to the images themselves. e.g. the blog.
|
||||
.imgs-fluid
|
||||
img
|
||||
// Just re-use Bootstrap's mixin here.
|
||||
+img-fluid
|
||||
|
||||
.overflow-hidden
|
||||
overflow: hidden
|
||||
|
||||
=text-gradient($color_from, $color_to)
|
||||
background: linear-gradient(to right, $color_from, $color_to)
|
||||
background-clip: text
|
||||
-webkit-background-clip: text
|
||||
-webkit-text-fill-color: transparent
|
||||
|
||||
=active-gradient
|
||||
+text-gradient($primary-accent, $primary)
|
||||
|
||||
&:before
|
||||
+text-gradient($primary-accent, $primary)
|
||||
|
||||
.title-underline
|
||||
padding-bottom: 5px
|
||||
position: relative
|
||||
margin-bottom: 20px
|
||||
|
||||
&:before
|
||||
background-color: $primary
|
||||
content: ' '
|
||||
display: block
|
||||
height: 2px
|
||||
top: 125%
|
||||
position: absolute
|
||||
width: 50px
|
||||
|
1290
src/styles/base.sass
1290
src/styles/base.sass
File diff suppressed because it is too large
Load Diff
@ -1,33 +1,58 @@
|
||||
@import _normalize
|
||||
// Bootstrap variables and utilities.
|
||||
@import "../../node_modules/bootstrap/scss/functions"
|
||||
@import "../../node_modules/bootstrap/scss/variables"
|
||||
@import "../../node_modules/bootstrap/scss/mixins"
|
||||
|
||||
@import _config
|
||||
@import _utils
|
||||
|
||||
// Bootstrap components.
|
||||
@import "../../node_modules/bootstrap/scss/root"
|
||||
@import "../../node_modules/bootstrap/scss/reboot"
|
||||
|
||||
@import "../../node_modules/bootstrap/scss/type"
|
||||
@import "../../node_modules/bootstrap/scss/images"
|
||||
|
||||
@import "../../node_modules/bootstrap/scss/code"
|
||||
@import "../../node_modules/bootstrap/scss/grid"
|
||||
@import "../../node_modules/bootstrap/scss/buttons"
|
||||
@import "../../node_modules/bootstrap/scss/dropdown"
|
||||
@import "../../node_modules/bootstrap/scss/custom-forms"
|
||||
|
||||
@import "../../node_modules/bootstrap/scss/nav"
|
||||
@import "../../node_modules/bootstrap/scss/navbar"
|
||||
|
||||
@import "../../node_modules/bootstrap/scss/card"
|
||||
@import "../../node_modules/bootstrap/scss/jumbotron"
|
||||
@import "../../node_modules/bootstrap/scss/media"
|
||||
@import "../../node_modules/bootstrap/scss/close"
|
||||
@import "../../node_modules/bootstrap/scss/modal"
|
||||
@import "../../node_modules/bootstrap/scss/tooltip"
|
||||
|
||||
@import "../../node_modules/bootstrap/scss/utilities"
|
||||
|
||||
// Pillar components.
|
||||
@import "apps_base"
|
||||
@import "components/base"
|
||||
|
||||
@import "components/card"
|
||||
@import "components/jumbotron"
|
||||
@import "components/navbar"
|
||||
@import "components/dropdown"
|
||||
@import "components/footer"
|
||||
@import "components/shortcode"
|
||||
@import "components/flyout"
|
||||
@import "components/buttons"
|
||||
@import "components/tooltip"
|
||||
@import "components/overlay"
|
||||
|
||||
@import _comments
|
||||
@import _error
|
||||
@import _search
|
||||
|
||||
.container-fluid.blog
|
||||
padding: 0
|
||||
|
||||
#blog_container
|
||||
+media-xs
|
||||
flex-direction: column
|
||||
padding-top: 0
|
||||
display: flex
|
||||
padding:
|
||||
bottom: 15px
|
||||
|
||||
video
|
||||
max-width: 100%
|
||||
@import _notifications
|
||||
|
||||
#blog_post-edit-form
|
||||
padding: 20px
|
||||
|
||||
.form-group
|
||||
position: relative
|
||||
margin: 0 auto 30px auto
|
||||
font-family: $font-body
|
||||
|
||||
input, textarea, select
|
||||
+input-generic
|
||||
|
||||
@ -95,7 +120,6 @@
|
||||
margin-bottom: 15px
|
||||
border-top: thin solid $color-text-dark-hint
|
||||
|
||||
|
||||
.form-group.description,
|
||||
.form-group.summary,
|
||||
.form-group.content
|
||||
@ -163,14 +187,10 @@
|
||||
color: transparent
|
||||
|
||||
|
||||
#blog_post-create-container,
|
||||
#blog_post-edit-container
|
||||
padding: 25px
|
||||
|
||||
#blog_index-container,
|
||||
#blog_post-create-container,
|
||||
#blog_post-edit-container
|
||||
+container-box
|
||||
padding: 25px
|
||||
width: 75%
|
||||
|
||||
+media-xs
|
||||
@ -185,133 +205,6 @@
|
||||
+media-lg
|
||||
width: 100%
|
||||
|
||||
.blog_index-header
|
||||
border-top-left-radius: 3px
|
||||
border-top-right-radius: 3px
|
||||
display: block
|
||||
overflow: hidden
|
||||
position: relative
|
||||
text-align: center
|
||||
width: 100%
|
||||
|
||||
img
|
||||
width: 100%
|
||||
|
||||
.blog_index-item
|
||||
+media-lg
|
||||
max-width: 780px
|
||||
+media-md
|
||||
max-width: 780px
|
||||
+media-sm
|
||||
max-width: 780px
|
||||
|
||||
margin: 15px auto
|
||||
|
||||
&:hover
|
||||
.item-info a
|
||||
color: $color-primary
|
||||
|
||||
.item-picture
|
||||
position: relative
|
||||
width: 100%
|
||||
max-height: 350px
|
||||
min-height: 200px
|
||||
height: auto
|
||||
overflow: hidden
|
||||
border-top-left-radius: 3px
|
||||
border-top-right-radius: 3px
|
||||
+clearfix
|
||||
|
||||
img
|
||||
+position-center-translate
|
||||
width: 100%
|
||||
border-top-left-radius: 3px
|
||||
border-top-right-radius: 3px
|
||||
|
||||
+media-xs
|
||||
min-height: 150px
|
||||
+media-sm
|
||||
min-height: 150px
|
||||
+media-md
|
||||
min-height: 250px
|
||||
+media-lg
|
||||
min-height: 250px
|
||||
|
||||
.item-title
|
||||
color: $color-text-dark
|
||||
display: block
|
||||
font:
|
||||
family: $font-body
|
||||
size: 1.8em
|
||||
|
||||
padding: 10px 25px 10px
|
||||
|
||||
ul.meta
|
||||
+list-meta
|
||||
font-size: .9em
|
||||
padding: 0px 25px 5px
|
||||
|
||||
.item-content
|
||||
+node-details-description
|
||||
font-size: 1.3em
|
||||
padding: 15px 25px 25px
|
||||
|
||||
+media-xs
|
||||
padding:
|
||||
left: 0
|
||||
right: 0
|
||||
|
||||
img
|
||||
display: block
|
||||
margin: 0 auto
|
||||
|
||||
.item-meta
|
||||
color: $color-text-dark-secondary
|
||||
padding:
|
||||
left: 25px
|
||||
right: 25px
|
||||
|
||||
+media-xs
|
||||
padding:
|
||||
left: 10px
|
||||
right: 10px
|
||||
|
||||
.button-create,
|
||||
.button-edit
|
||||
+button($color-success, 3px, true)
|
||||
|
||||
.item-picture+.button-back+.button-edit
|
||||
right: 20px
|
||||
top: 20px
|
||||
|
||||
.comments-container
|
||||
padding:
|
||||
left: 20px
|
||||
right: 20px
|
||||
max-width: 680px
|
||||
margin: 0 auto
|
||||
|
||||
+media-lg
|
||||
padding:
|
||||
left: 0
|
||||
right: 0
|
||||
|
||||
.comment-reply-container
|
||||
background-color: transparent
|
||||
|
||||
.comment-reply-field
|
||||
textarea, .comment-reply-meta
|
||||
background-color: $color-background-light
|
||||
|
||||
&.filled
|
||||
.comment-reply-meta
|
||||
background-color: $color-success
|
||||
|
||||
.comment-reply-form
|
||||
+media-xs
|
||||
padding:
|
||||
left: 0
|
||||
|
||||
#blog_post-edit-form
|
||||
padding: 0
|
||||
|
||||
@ -346,294 +239,3 @@
|
||||
|
||||
.form-upload-file-meta
|
||||
width: initial
|
||||
|
||||
#blog_post-edit-title
|
||||
padding: 0
|
||||
color: $color-text
|
||||
font:
|
||||
size: 1.8em
|
||||
weight: 300
|
||||
margin: 0 20px 15px 0
|
||||
|
||||
#blog_index-sidebar
|
||||
width: 25%
|
||||
padding: 0 15px
|
||||
|
||||
+media-xs
|
||||
width: 100%
|
||||
clear: both
|
||||
display: block
|
||||
margin-top: 25px
|
||||
+media-sm
|
||||
width: 40%
|
||||
+media-md
|
||||
width: 30%
|
||||
+media-lg
|
||||
width: 25%
|
||||
|
||||
.button-create
|
||||
display: block
|
||||
width: 100%
|
||||
+button($color-success, 6px)
|
||||
margin: 0
|
||||
|
||||
.button-back
|
||||
+button($color-info, 6px, true)
|
||||
display: block
|
||||
width: 100%
|
||||
margin: 15px 0 0 0
|
||||
|
||||
#blog_post-edit-form
|
||||
.form-group
|
||||
.form-control
|
||||
background-color: white
|
||||
|
||||
.blog_index-sidebar,
|
||||
.blog_project-sidebar
|
||||
+container-box
|
||||
background-color: lighten($color-background, 5%)
|
||||
padding: 20px
|
||||
|
||||
.blog_project-card
|
||||
position: relative
|
||||
width: 100%
|
||||
border-radius: 3px
|
||||
overflow: hidden
|
||||
background-color: white
|
||||
color: lighten($color-text, 10%)
|
||||
box-shadow: 0 0 30px rgba(black, .2)
|
||||
|
||||
margin:
|
||||
top: 0
|
||||
bottom: 15px
|
||||
left: auto
|
||||
right: auto
|
||||
|
||||
|
||||
a.item-header
|
||||
position: relative
|
||||
width: 100%
|
||||
height: 100px
|
||||
display: block
|
||||
background-size: 100% 100%
|
||||
|
||||
overflow: hidden
|
||||
|
||||
.overlay
|
||||
z-index: 1
|
||||
width: 100%
|
||||
height: 100px
|
||||
@include overlay(transparent, 0%, white, 100%)
|
||||
|
||||
|
||||
img.background
|
||||
width: 100%
|
||||
transform: scale(1.4)
|
||||
|
||||
.card-thumbnail
|
||||
position: absolute
|
||||
z-index: 2
|
||||
height: 90px
|
||||
width: 90px
|
||||
display: block
|
||||
top: 35px
|
||||
left: 50%
|
||||
transform: translateX(-50%)
|
||||
background-color: white
|
||||
border-radius: 3px
|
||||
overflow: hidden
|
||||
|
||||
&:hover
|
||||
img.thumb
|
||||
opacity: .9
|
||||
|
||||
img.thumb
|
||||
width: 100%
|
||||
border-radius: 3px
|
||||
transition: opacity 150ms ease-in-out
|
||||
+position-center-translate
|
||||
|
||||
.item-info
|
||||
padding: 10px 20px
|
||||
background-color: white
|
||||
border-bottom-left-radius: 3px
|
||||
border-bottom-right-radius: 3px
|
||||
|
||||
a.item-title
|
||||
display: inline-block
|
||||
width: 100%
|
||||
padding: 30px 0 15px 0
|
||||
color: $color-text-dark
|
||||
text-align: center
|
||||
font:
|
||||
size: 1.6em
|
||||
weight: 300
|
||||
|
||||
transition: color 150ms ease-in-out
|
||||
|
||||
&:hover
|
||||
text-decoration: none
|
||||
color: $color-primary
|
||||
|
||||
|
||||
#blog_container
|
||||
&.cloud-blog
|
||||
#blog_index-container,
|
||||
#blog_post-create-container,
|
||||
#blog_post-edit-container
|
||||
width: 100%
|
||||
padding: 25px 30px 20px 30px
|
||||
|
||||
#blog_index-container+#blog_index-sidebar
|
||||
display: none
|
||||
|
||||
#blog_index-container,
|
||||
&.cloud-blog #blog_index-container
|
||||
+media-sm
|
||||
width: 100%
|
||||
|
||||
+media-xs
|
||||
width: 100%
|
||||
|
||||
padding: 0 0 50px 0
|
||||
margin: 0 auto
|
||||
|
||||
.blog_index-item
|
||||
+media-xs
|
||||
width: 100%
|
||||
padding: 10px 25px
|
||||
|
||||
&.list
|
||||
margin: 0 auto
|
||||
padding: 15px 0
|
||||
margin: 0 auto
|
||||
border-bottom: thin solid $color-background
|
||||
|
||||
&:last-child
|
||||
border-bottom: none
|
||||
|
||||
+media-xs
|
||||
width: 100%
|
||||
padding: 15px 10px
|
||||
margin: 0
|
||||
|
||||
a.item-title
|
||||
padding:
|
||||
top: 0
|
||||
bottom: 5px
|
||||
font:
|
||||
size: 1.6em
|
||||
weight: 400
|
||||
family: $font-body
|
||||
|
||||
.item-info
|
||||
color: $color-text-dark-secondary
|
||||
font-size: .9em
|
||||
padding:
|
||||
left: 25px
|
||||
right: 25px
|
||||
|
||||
.item-header
|
||||
width: 50px
|
||||
height: 50px
|
||||
position: absolute
|
||||
top: 20px
|
||||
border-radius: 3px
|
||||
background-color: $color-background
|
||||
overflow: hidden
|
||||
|
||||
img
|
||||
+position-center-translate
|
||||
width: 100%
|
||||
|
||||
i
|
||||
+position-center-translate
|
||||
font-size: 1.2em
|
||||
color: $color-text-dark-hint
|
||||
|
||||
&.nothumb
|
||||
border-radius: 50%
|
||||
|
||||
a.item-title, .item-info
|
||||
padding-left: 70px
|
||||
|
||||
#blog_index-container
|
||||
.blog_index-item
|
||||
position: relative
|
||||
|
||||
+media-xs
|
||||
padding: 25px 0 20px 0
|
||||
|
||||
&.list
|
||||
padding: 15px 10px
|
||||
margin: 0
|
||||
|
||||
+media-xs
|
||||
width: 100%
|
||||
padding: 15px 10px
|
||||
margin: 0
|
||||
|
||||
|
||||
.blog-archive-navigation
|
||||
+media-xs
|
||||
font-size: 1em
|
||||
max-width: initial
|
||||
|
||||
border-bottom: thin solid $color-background-dark
|
||||
display: flex
|
||||
font:
|
||||
size: 1.2em
|
||||
weight: 300
|
||||
margin: 0 auto
|
||||
max-width: 780px
|
||||
text-align: center
|
||||
+text-overflow-ellipsis
|
||||
|
||||
&:last-child
|
||||
border: none
|
||||
|
||||
a, span
|
||||
+media-xs
|
||||
padding: 10px
|
||||
|
||||
flex: 1
|
||||
padding: 25px 15px
|
||||
|
||||
span
|
||||
color: $color-text-dark-secondary
|
||||
pointer-events: none
|
||||
|
||||
.blog-action
|
||||
display: flex
|
||||
padding: 10px
|
||||
position: absolute
|
||||
right: 0
|
||||
top: 0
|
||||
z-index: 1
|
||||
|
||||
|
||||
// Specific tweaks for blogs in the context of a project
|
||||
#project_context
|
||||
.blog_index-item
|
||||
+media-xs
|
||||
margin-left: 0
|
||||
padding: 0
|
||||
margin-left: 10px
|
||||
|
||||
&.list
|
||||
margin-left: 35px !important
|
||||
|
||||
.item-title,
|
||||
.item-info
|
||||
+media-xs
|
||||
padding-left: 0
|
||||
padding-left: 25px
|
||||
|
||||
#blog_container
|
||||
.comments-container
|
||||
+media-sm
|
||||
margin-left: 10px
|
||||
margin-left: 30px
|
||||
|
||||
.blog-archive-navigation
|
||||
margin-left: 35px
|
||||
|
72
src/styles/components/_alerts.sass
Normal file
72
src/styles/components/_alerts.sass
Normal 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
|
29
src/styles/components/_base.sass
Normal file
29
src/styles/components/_base.sass
Normal 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
|
14
src/styles/components/_buttons.sass
Normal file
14
src/styles/components/_buttons.sass
Normal 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
|
142
src/styles/components/_card.sass
Normal file
142
src/styles/components/_card.sass
Normal 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
|
8
src/styles/components/_checkbox.sass
Normal file
8
src/styles/components/_checkbox.sass
Normal 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
|
44
src/styles/components/_dropdown.sass
Normal file
44
src/styles/components/_dropdown.sass
Normal 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
|
25
src/styles/components/_flyout.sass
Normal file
25
src/styles/components/_flyout.sass
Normal 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
|
117
src/styles/components/_footer.sass
Normal file
117
src/styles/components/_footer.sass
Normal 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
|
132
src/styles/components/_forms.sass
Normal file
132
src/styles/components/_forms.sass
Normal 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
|
38
src/styles/components/_inputs.sass
Normal file
38
src/styles/components/_inputs.sass
Normal 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
|
47
src/styles/components/_jumbotron.sass
Normal file
47
src/styles/components/_jumbotron.sass
Normal 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
|
255
src/styles/components/_navbar.sass
Normal file
255
src/styles/components/_navbar.sass
Normal 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
|
75
src/styles/components/_overlay.sass
Normal file
75
src/styles/components/_overlay.sass
Normal 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
|
26
src/styles/components/_popover.sass
Normal file
26
src/styles/components/_popover.sass
Normal 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%)
|
87
src/styles/components/_search.sass
Normal file
87
src/styles/components/_search.sass
Normal 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)
|
6
src/styles/components/_shortcode.sass
Normal file
6
src/styles/components/_shortcode.sass
Normal 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
|
21
src/styles/components/_statusbar.sass
Normal file
21
src/styles/components/_statusbar.sass
Normal 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
|
||||
|
5
src/styles/components/_tooltip.sass
Normal file
5
src/styles/components/_tooltip.sass
Normal file
@ -0,0 +1,5 @@
|
||||
.tooltip
|
||||
transition: none
|
||||
|
||||
.tooltip-inner
|
||||
white-space: nowrap
|
@ -1,9 +1,12 @@
|
||||
/* So it's possible to override the path before importing font-pillar.sass */
|
||||
/* SCROLL TO READ ABOUT UPDATING THIS FILE FROM FONTELLO */
|
||||
|
||||
/* Makes it possible to override the path before importing font-pillar.sass */
|
||||
$pillar-font-path: "../font" !default
|
||||
|
||||
/* Font properties. */
|
||||
@font-face
|
||||
font-family: 'pillar-font'
|
||||
src: url('#{$pillar-font-path}/pillar-font.woff?55726379') format("woff"), url('#{$pillar-font-path}/pillar-font.woff2?55726379') format("woff2")
|
||||
src: url('#{$pillar-font-path}/pillar-font.woff?54788822') format("woff"), url('#{$pillar-font-path}/pillar-font.woff2?54788822') format("woff2")
|
||||
font-weight: normal
|
||||
font-style: normal
|
||||
|
||||
@ -17,23 +20,99 @@ $pillar-font-path: "../font" !default
|
||||
width: 1em
|
||||
margin-right: .2em
|
||||
text-align: center
|
||||
|
||||
/* opacity: .8;
|
||||
/* For safety - reset parent styles, that can break glyph codes
|
||||
font-variant: normal
|
||||
text-transform: none
|
||||
/* fix buttons height, for twitter bootstrap
|
||||
line-height: 1em
|
||||
/* Animation center compensation - margins should be symmetric
|
||||
/* remove if not needed
|
||||
margin-left: .2em
|
||||
/* you can be more comfortable with increased icons size
|
||||
/* font-size: 120%;
|
||||
/* Font smoothing. That was taken from TWBS
|
||||
-webkit-font-smoothing: antialiased
|
||||
-moz-osx-font-smoothing: grayscale
|
||||
/* Uncomment for 3D effect
|
||||
/* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3);
|
||||
|
||||
/* Icon aliases. */
|
||||
/* Empty icons, multiple names for the same/unasigned icon, etc. */
|
||||
.pi, .pi-blank
|
||||
&:after
|
||||
content: ''
|
||||
font-family: "pillar-font"
|
||||
font-style: normal
|
||||
font-weight: normal
|
||||
speak: none
|
||||
display: inline-block
|
||||
text-decoration: inherit
|
||||
width: 1em
|
||||
text-align: center
|
||||
font-variant: normal
|
||||
text-transform: none
|
||||
line-height: 1em
|
||||
-webkit-font-smoothing: antialiased
|
||||
-moz-osx-font-smoothing: grayscale
|
||||
position: relative
|
||||
|
||||
&:before
|
||||
position: relative
|
||||
|
||||
.pi-svnman:before
|
||||
content: '\f1c0'
|
||||
|
||||
/* Assets */
|
||||
.pi-group
|
||||
@extend .pi-folder
|
||||
.pi-video
|
||||
@extend .pi-film-thick
|
||||
.pi-file
|
||||
@extend .pi-file-archive
|
||||
.pi-asset
|
||||
@extend .pi-file-archive
|
||||
.pi-group_texture
|
||||
@extend .pi-folder-texture
|
||||
.pi-post
|
||||
@extend .pi-newspaper
|
||||
.pi-page
|
||||
@extend .pi-document
|
||||
|
||||
/* License */
|
||||
.pi-license-cc-zero:before
|
||||
content: '\e85a'
|
||||
.pi-license-cc-sa:before
|
||||
content: '\e858'
|
||||
top: 1px
|
||||
.pi-license-cc-nd:before
|
||||
content: '\e859'
|
||||
.pi-license-cc-nc:before
|
||||
content: '\e857'
|
||||
|
||||
.pi-license-cc-0
|
||||
@extend .pi-license-cc-zero
|
||||
position: relative
|
||||
top: 1px
|
||||
.pi-license-cc-by-sa
|
||||
@extend .pi-license-cc-sa
|
||||
.pi-license-cc-by-nd
|
||||
@extend .pi-license-cc-nd
|
||||
.pi-license-cc-by-nc
|
||||
@extend .pi-license-cc-nc
|
||||
|
||||
.pi-license-cc-by-sa,
|
||||
.pi-license-cc-by-nd,
|
||||
.pi-license-cc-by-nc
|
||||
@extend .pi
|
||||
|
||||
&:after
|
||||
content: '\e807'
|
||||
left: -27px
|
||||
|
||||
&:before
|
||||
left: 27px
|
||||
|
||||
|
||||
/*
|
||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
* Here begins the CSS code generated by fontello.com by using *
|
||||
* the config.json file in /pillar/web/static/assets/font *
|
||||
* Just convert the icon classes from pillar-font.css to Sass *
|
||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
* When adding icons, only add/overwrite icon classes e.g. .pi-bla *
|
||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
*/
|
||||
|
||||
.pi-collection-plus:before
|
||||
content: '\e800'
|
||||
@ -430,6 +509,11 @@ $pillar-font-path: "../font" !default
|
||||
|
||||
/* ''
|
||||
|
||||
.pi-speed:before
|
||||
content: '\e84f'
|
||||
|
||||
/* ''
|
||||
|
||||
.pi-attention:before
|
||||
content: '\e850'
|
||||
|
||||
@ -580,11 +664,6 @@ $pillar-font-path: "../font" !default
|
||||
|
||||
/* ''
|
||||
|
||||
.pi-users:before
|
||||
content: '\e86e'
|
||||
|
||||
/* ''
|
||||
|
||||
.pi-flamenco:before
|
||||
content: '\e86f'
|
||||
|
||||
@ -605,6 +684,11 @@ $pillar-font-path: "../font" !default
|
||||
|
||||
/* ''
|
||||
|
||||
.pi-users:before
|
||||
content: '\e873'
|
||||
|
||||
/* ''
|
||||
|
||||
.pi-pause:before
|
||||
content: '\f00e'
|
||||
|
||||
@ -640,6 +724,16 @@ $pillar-font-path: "../font" !default
|
||||
|
||||
/* ''
|
||||
|
||||
.pi-social-instagram:before
|
||||
content: '\f16d'
|
||||
|
||||
/* ''
|
||||
|
||||
.pi-database:before
|
||||
content: '\f1c0'
|
||||
|
||||
/* ''
|
||||
|
||||
.pi-newspaper:before
|
||||
content: '\f1ea'
|
||||
|
||||
|
@ -1,15 +1,84 @@
|
||||
@import _normalize
|
||||
// Bootstrap variables and utilities.
|
||||
@import "../../node_modules/bootstrap/scss/functions"
|
||||
@import "../../node_modules/bootstrap/scss/variables"
|
||||
@import "../../node_modules/bootstrap/scss/mixins"
|
||||
|
||||
@import _config
|
||||
@import _utils
|
||||
|
||||
// Bootstrap components.
|
||||
@import "../../node_modules/bootstrap/scss/root"
|
||||
@import "../../node_modules/bootstrap/scss/reboot"
|
||||
|
||||
@import "../../node_modules/bootstrap/scss/type"
|
||||
@import "../../node_modules/bootstrap/scss/images"
|
||||
|
||||
@import "../../node_modules/bootstrap/scss/code"
|
||||
@import "../../node_modules/bootstrap/scss/grid"
|
||||
@import "../../node_modules/bootstrap/scss/tables"
|
||||
@import "../../node_modules/bootstrap/scss/forms"
|
||||
@import "../../node_modules/bootstrap/scss/buttons"
|
||||
@import "../../node_modules/bootstrap/scss/transitions"
|
||||
@import "../../node_modules/bootstrap/scss/dropdown"
|
||||
@import "../../node_modules/bootstrap/scss/button-group"
|
||||
@import "../../node_modules/bootstrap/scss/input-group"
|
||||
@import "../../node_modules/bootstrap/scss/custom-forms"
|
||||
|
||||
@import "../../node_modules/bootstrap/scss/nav"
|
||||
@import "../../node_modules/bootstrap/scss/navbar"
|
||||
|
||||
@import "../../node_modules/bootstrap/scss/card"
|
||||
@import "../../node_modules/bootstrap/scss/breadcrumb"
|
||||
@import "../../node_modules/bootstrap/scss/pagination"
|
||||
@import "../../node_modules/bootstrap/scss/badge"
|
||||
@import "../../node_modules/bootstrap/scss/jumbotron"
|
||||
@import "../../node_modules/bootstrap/scss/alert"
|
||||
@import "../../node_modules/bootstrap/scss/progress"
|
||||
@import "../../node_modules/bootstrap/scss/media"
|
||||
@import "../../node_modules/bootstrap/scss/list-group"
|
||||
@import "../../node_modules/bootstrap/scss/close"
|
||||
@import "../../node_modules/bootstrap/scss/modal"
|
||||
@import "../../node_modules/bootstrap/scss/tooltip"
|
||||
@import "../../node_modules/bootstrap/scss/popover"
|
||||
@import "../../node_modules/bootstrap/scss/carousel"
|
||||
|
||||
@import "../../node_modules/bootstrap/scss/utilities"
|
||||
@import "../../node_modules/bootstrap/scss/print"
|
||||
|
||||
// Pillar components.
|
||||
@import "apps_base"
|
||||
@import "components/base"
|
||||
|
||||
@import "components/jumbotron"
|
||||
@import "components/alerts"
|
||||
@import "components/navbar"
|
||||
@import "components/dropdown"
|
||||
@import "components/footer"
|
||||
@import "components/shortcode"
|
||||
@import "components/statusbar"
|
||||
@import "components/search"
|
||||
|
||||
@import "components/flyout"
|
||||
@import "components/forms"
|
||||
@import "components/inputs"
|
||||
@import "components/buttons"
|
||||
@import "components/popover"
|
||||
@import "components/tooltip"
|
||||
@import "components/checkbox"
|
||||
@import "components/overlay"
|
||||
@import "components/card"
|
||||
|
||||
|
||||
/* Generic styles (comments, notifications, etc) come from base.css */
|
||||
@import _notifications
|
||||
@import _comments
|
||||
|
||||
@import _project
|
||||
@import _project-sharing
|
||||
@import _project-dashboard
|
||||
@import _user
|
||||
@import _search
|
||||
@import _organizations
|
||||
@import _search
|
||||
|
||||
/* services, about, etc */
|
||||
@import _pages
|
||||
|
@ -1,9 +1,8 @@
|
||||
/* jsTree overrides */
|
||||
|
||||
$tree-color-text: $color-text-dark-primary
|
||||
$tree-color-highlight: hsl(hue($color-background-active), 40%, 50%)
|
||||
$tree-color-highlight-background: hsl(hue($color-background-active), 40%, 50%)
|
||||
$tree-color-highlight-background-text: white
|
||||
$tree-color-highlight: $color-primary-accent
|
||||
$tree-color-highlight-background: $white
|
||||
$tree-color-highlight-background-text: $primary
|
||||
|
||||
.jstree-default
|
||||
/* list item */
|
||||
@ -34,11 +33,10 @@ $tree-color-highlight-background-text: white
|
||||
|
||||
&[data-node-type="page"],
|
||||
&[data-node-type="blog"]
|
||||
color: darken($tree-color-highlight, 5%)
|
||||
font-weight: bold
|
||||
|
||||
.jstree-anchor
|
||||
padding: 5px 8px 1px 8px
|
||||
padding: 0 6px
|
||||
|
||||
&:after
|
||||
top: 3px !important
|
||||
@ -63,49 +61,48 @@ $tree-color-highlight-background-text: white
|
||||
&.jstree-open
|
||||
/* Text of children for an open tree (like a folder) */
|
||||
.jstree-children > .jstree-node
|
||||
padding-left: 15px !important
|
||||
padding-left: 16px !important
|
||||
|
||||
.jstree-icon:empty
|
||||
left: 20px !important
|
||||
|
||||
// Tweaks for specific icons
|
||||
&.pi-file-archive
|
||||
left: 22px !important
|
||||
left: 25px !important
|
||||
&.pi-folder
|
||||
left: 21px !important
|
||||
left: 20px !important
|
||||
font-size: .9em !important
|
||||
&.pi-film-thick
|
||||
left: 22px !important
|
||||
&.pi-splay
|
||||
left: 20px !important
|
||||
font-size: .85em !important
|
||||
|
||||
.jstree-anchor
|
||||
box-shadow: inset 1px 0 0 0 rgba($tree-color-text, .2)
|
||||
// box-shadow: inset 1px 0 0 0 $color-background
|
||||
|
||||
/* Closed Folder */
|
||||
// &.jstree-closed
|
||||
|
||||
&.jstree-open .jstree-icon.jstree-ocl,
|
||||
&.jstree-closed .jstree-icon.jstree-ocl
|
||||
float: left
|
||||
min-width: 30px
|
||||
opacity: 0
|
||||
position: absolute
|
||||
z-index: 1
|
||||
opacity: 0
|
||||
min-width: 30px
|
||||
float: left
|
||||
|
||||
/* The text of the last level item */
|
||||
.jstree-anchor
|
||||
+media-xs
|
||||
width: 98%
|
||||
padding: 0 !important
|
||||
width: 98%
|
||||
border: none
|
||||
font-size: 13px
|
||||
height: inherit
|
||||
line-height: 26px
|
||||
line-height: 24px
|
||||
overflow: hidden
|
||||
padding-left: 28px
|
||||
padding-right: 10px
|
||||
text-overflow: ellipsis
|
||||
transition: none
|
||||
transition: color 50ms ease-in-out, background-color 100ms ease-in-out
|
||||
white-space: nowrap
|
||||
width: 100%
|
||||
|
||||
@ -113,7 +110,7 @@ $tree-color-highlight-background-text: white
|
||||
&:after
|
||||
content: '\e83a' !important
|
||||
font-family: 'pillar-font'
|
||||
color: white
|
||||
color: $tree-color-highlight-background-text
|
||||
display: none
|
||||
position: absolute
|
||||
right: 7px
|
||||
@ -121,34 +118,29 @@ $tree-color-highlight-background-text: white
|
||||
|
||||
// Icon, not selected
|
||||
.jstree-icon
|
||||
color: $color-text-dark-secondary
|
||||
font-size: 95% !important
|
||||
color: $tree-color-text
|
||||
margin: 0 !important
|
||||
|
||||
/* Selected item */
|
||||
&.jstree-clicked
|
||||
background-color: $tree-color-highlight-background !important
|
||||
color: white !important
|
||||
color: $tree-color-highlight-background-text !important
|
||||
font-weight: bold
|
||||
|
||||
&:after
|
||||
display: block
|
||||
color: white !important
|
||||
color: $tree-color-highlight-background-text !important
|
||||
|
||||
.jstree-ocl,
|
||||
.jstree-icon
|
||||
color: white
|
||||
color: $tree-color-highlight-background-text
|
||||
|
||||
/* hover an active item */
|
||||
&.jstree-hovered
|
||||
background-color: lighten($tree-color-highlight-background, 10%) !important
|
||||
box-shadow: none
|
||||
color: white !important
|
||||
color: $tree-color-highlight-background-text !important
|
||||
|
||||
&.jstree-hovered .jstree-icon
|
||||
color: white !important
|
||||
|
||||
.jstree-hovered
|
||||
background-color: rgba($tree-color-highlight, .1) !important
|
||||
color: $tree-color-highlight-background-text !important
|
||||
|
||||
.jstree-leaf .jstree-clicked
|
||||
width: 100% !important
|
||||
@ -184,8 +176,8 @@ $tree-color-highlight-background-text: white
|
||||
position: absolute
|
||||
|
||||
&:empty
|
||||
line-height: 26px
|
||||
left: 5px
|
||||
line-height: 24px
|
||||
left: 3px
|
||||
|
||||
&.is_subscriber
|
||||
.jstree-node
|
||||
@ -196,63 +188,6 @@ $tree-color-highlight-background-text: white
|
||||
&:after
|
||||
display: none !important
|
||||
|
||||
&.blog
|
||||
.jstree-anchor
|
||||
padding: 6px 6px 6px 12px
|
||||
|
||||
&:hover
|
||||
color: $tree-color-highlight
|
||||
|
||||
&.post
|
||||
border-bottom: thin solid $color-background-dark
|
||||
|
||||
&.jstree-clicked
|
||||
&.post
|
||||
background-color: transparent !important
|
||||
|
||||
&:after
|
||||
top: 8px
|
||||
color: $tree-color-highlight !important
|
||||
|
||||
.tree-item-info
|
||||
color: $color-text
|
||||
|
||||
.tree-item-title
|
||||
color: $tree-color-highlight
|
||||
|
||||
.tree-item
|
||||
line-height: initial
|
||||
padding-right: 10px
|
||||
|
||||
&-title
|
||||
font-size: 1.2em
|
||||
overflow: initial
|
||||
text-overflow: initial
|
||||
white-space: normal
|
||||
|
||||
&-info
|
||||
color: $color-text-dark-secondary
|
||||
display: block
|
||||
font-size: .8em
|
||||
padding: 5px
|
||||
|
||||
&-thumbnail
|
||||
align-items: center
|
||||
background-color: $color-background
|
||||
border-radius: 3px
|
||||
color: $color-text-dark-secondary
|
||||
display: flex
|
||||
float: left
|
||||
height: 70px
|
||||
justify-content: center
|
||||
margin: 0 10px 0 -5px
|
||||
width: 70px
|
||||
|
||||
img
|
||||
height: 70px
|
||||
width: 70px
|
||||
|
||||
|
||||
.jstree-loading
|
||||
padding: 5px
|
||||
color: $color-text-light-secondary
|
||||
@ -269,7 +204,7 @@ $tree-color-highlight-background-text: white
|
||||
|
||||
.jstree-default .jstree-node.jstree-closed .jstree-icon.jstree-ocl + .jstree-anchor,
|
||||
.jstree-default .jstree-node.jstree-open .jstree-icon.jstree-ocl + .jstree-anchor
|
||||
padding-left: 28px !important
|
||||
padding-left: 24px !important
|
||||
|
||||
/* hovered text */
|
||||
.jstree-default .jstree-hovered,
|
||||
@ -279,12 +214,10 @@ $tree-color-highlight-background-text: white
|
||||
|
||||
|
||||
a.jstree-anchor.jstree-clicked+ul li a.jstree-anchor.jstree-clicked
|
||||
background-color: rgba($tree-color-highlight-background, .8) !important
|
||||
color: white !important
|
||||
color: $tree-color-highlight-background-text !important
|
||||
|
||||
a.jstree-anchor.jstree-clicked+ul li a.jstree-anchor.jstree-clicked+ul li a.jstree-anchor.jstree-clicked
|
||||
background-color: rgba($tree-color-highlight-background, .8) !important
|
||||
color: white !important
|
||||
color: $tree-color-highlight-background-text !important
|
||||
|
||||
i.jstree-icon.jstree-ocl
|
||||
color: rgba($tree-color-text, .5) !important
|
||||
|
@ -1,5 +1,7 @@
|
||||
$videoplayer-controls-color: white
|
||||
$videoplayer-background-color: $color-background-nav
|
||||
$videoplayer-background-color: darken($primary, 10%)
|
||||
|
||||
$videoplayer-progress-bar-height: .5em
|
||||
|
||||
.video-js
|
||||
.vjs-big-play-button:before, .vjs-control:before, .vjs-modal-dialog
|
||||
@ -30,7 +32,6 @@ $videoplayer-background-color: $color-background-nav
|
||||
font-weight: normal
|
||||
font-style: normal
|
||||
|
||||
|
||||
.vjs-icon-play
|
||||
font-family: VideoJS
|
||||
font-weight: normal
|
||||
@ -285,7 +286,6 @@ $videoplayer-background-color: $color-background-nav
|
||||
line-height: 1
|
||||
font-weight: normal
|
||||
font-style: normal
|
||||
font-family: Arial, Helvetica, sans-serif
|
||||
-webkit-user-select: none
|
||||
-moz-user-select: none
|
||||
-ms-user-select: none
|
||||
@ -453,20 +453,22 @@ body.vjs-full-window
|
||||
list-style: none
|
||||
margin: 0
|
||||
padding: 0.2em 0
|
||||
line-height: 1.4em
|
||||
font-size: 1.2em
|
||||
line-height: 1.8em
|
||||
font-size: 1.1em
|
||||
text-align: center
|
||||
text-transform: lowercase
|
||||
|
||||
&:focus, &:hover
|
||||
outline: 0
|
||||
background-color: #73859f
|
||||
background-color: rgba(115, 133, 159, 0.5)
|
||||
background-color: darken($primary, 20%)
|
||||
|
||||
&.vjs-selected
|
||||
background-color: $videoplayer-controls-color
|
||||
color: $videoplayer-background-color
|
||||
|
||||
&:focus, &:hover
|
||||
background-color: $videoplayer-controls-color
|
||||
color: $videoplayer-background-color
|
||||
|
||||
&.vjs-menu-title
|
||||
text-align: center
|
||||
text-transform: uppercase
|
||||
@ -486,12 +488,13 @@ body.vjs-full-window
|
||||
height: 0em
|
||||
margin-bottom: 1.5em
|
||||
border-top-color: $videoplayer-background-color
|
||||
|
||||
.vjs-menu-content
|
||||
background-color: $videoplayer-background-color
|
||||
position: absolute
|
||||
width: 100%
|
||||
bottom: 1.5em
|
||||
max-height: 15em
|
||||
max-height: 25em
|
||||
|
||||
.vjs-workinghover .vjs-menu-button-popup:hover .vjs-menu, .vjs-menu-button-popup .vjs-menu.vjs-lock-showing
|
||||
display: block
|
||||
@ -655,12 +658,12 @@ body.vjs-full-window
|
||||
-moz-transition: all 0.2s
|
||||
-o-transition: all 0.2s
|
||||
transition: all 0.2s
|
||||
height: 0.3em
|
||||
height: $videoplayer-progress-bar-height
|
||||
|
||||
.vjs-play-progress
|
||||
position: absolute
|
||||
display: block
|
||||
height: 0.3em
|
||||
height: $videoplayer-progress-bar-height
|
||||
margin: 0
|
||||
padding: 0
|
||||
width: 0
|
||||
@ -670,7 +673,7 @@ body.vjs-full-window
|
||||
.vjs-load-progress
|
||||
position: absolute
|
||||
display: block
|
||||
height: 0.3em
|
||||
height: $videoplayer-progress-bar-height
|
||||
margin: 0
|
||||
padding: 0
|
||||
width: 0
|
||||
@ -680,7 +683,7 @@ body.vjs-full-window
|
||||
div
|
||||
position: absolute
|
||||
display: block
|
||||
height: 0.3em
|
||||
height: $videoplayer-progress-bar-height
|
||||
margin: 0
|
||||
padding: 0
|
||||
width: 0
|
||||
@ -692,10 +695,11 @@ body.vjs-full-window
|
||||
|
||||
.vjs-play-progress
|
||||
background-color: $videoplayer-controls-color
|
||||
border-radius: 999em
|
||||
|
||||
&:before
|
||||
position: absolute
|
||||
top: -0.333333333333333em
|
||||
top: -($videoplayer-progress-bar-height / 2) // halfway the height of the progress bar
|
||||
right: -0.5em
|
||||
|
||||
&:after
|
||||
@ -712,8 +716,8 @@ body.vjs-full-window
|
||||
z-index: 1
|
||||
|
||||
.vjs-time-tooltip
|
||||
background-color: $videoplayer-background-color
|
||||
color: $videoplayer-controls-color
|
||||
background-color: $videoplayer-background-color
|
||||
z-index: 1
|
||||
|
||||
&:after
|
||||
@ -735,9 +739,9 @@ body.vjs-full-window
|
||||
|
||||
.vjs-time-tooltip
|
||||
background-color: $videoplayer-controls-color
|
||||
border-radius: 3px
|
||||
border-radius: $border-radius
|
||||
color: $videoplayer-background-color
|
||||
font-family: $font-body
|
||||
font-family: $font-family-base
|
||||
font-size: 1.2em
|
||||
font-weight: bold
|
||||
padding: 5px 8px
|
||||
@ -851,9 +855,9 @@ body.vjs-full-window
|
||||
font-size: 0.9em
|
||||
|
||||
.vjs-slider-horizontal .vjs-volume-level
|
||||
height: 0.3em
|
||||
height: $videoplayer-progress-bar-height
|
||||
&:before
|
||||
top: -0.3em
|
||||
top: -$videoplayer-progress-bar-height
|
||||
right: -0.5em
|
||||
|
||||
.vjs-menu-button-popup
|
||||
@ -1022,14 +1026,15 @@ video::-webkit-media-text-track-display
|
||||
|
||||
.vjs-playback-rate
|
||||
.vjs-playback-rate-value
|
||||
font-size: 1.5em
|
||||
font-size: 1.25em
|
||||
line-height: 2
|
||||
position: absolute
|
||||
top: 0
|
||||
top: 3px
|
||||
left: 0
|
||||
width: 100%
|
||||
height: 100%
|
||||
text-align: center
|
||||
|
||||
.vjs-menu
|
||||
width: 4em
|
||||
left: 0em
|
||||
@ -1041,7 +1046,6 @@ video::-webkit-media-text-track-display
|
||||
&:before
|
||||
color: $videoplayer-controls-color
|
||||
content: 'X'
|
||||
font-family: Arial, Helvetica, sans-serif
|
||||
font-size: 4em
|
||||
left: 0
|
||||
line-height: 1
|
||||
|
@ -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
|
@ -7,8 +7,7 @@ $color-theatre-background-dark: darken($color-theatre-background, 5%)
|
||||
|
||||
$theatre-width: 350px
|
||||
|
||||
body.theatre,
|
||||
body.theatre .container-page
|
||||
body.theatre
|
||||
background-color: $color-theatre-background
|
||||
nav.navbar
|
||||
+media-lg
|
||||
@ -26,6 +25,7 @@ body.theatre .container-page
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
|
||||
.page-body
|
||||
height: 100%
|
||||
width: 100%
|
||||
|
@ -6,20 +6,22 @@
|
||||
| {% if node_type_name == 'group' %}
|
||||
| {% set node_type_name = 'folder' %}
|
||||
| {% endif %}
|
||||
li(class="button-{{ node_type['name'] }}")
|
||||
a.item_add_node(
|
||||
href="#",
|
||||
title="{{ node_type['description'] }}",
|
||||
data-node-type-name="{{ node_type['name'] }}",
|
||||
data-toggle="tooltip",
|
||||
data-placement="left")
|
||||
i.pi(class="icon-{{ node_type['name'] }}")
|
||||
li
|
||||
a.dropdown-item(
|
||||
class="item_add_node",
|
||||
href="#",
|
||||
title="{{ node_type['description'] }}",
|
||||
data-node-type-name="{{ node_type['name'] }}",
|
||||
data-toggle="tooltip",
|
||||
data-placement="left")
|
||||
i.pi(class="pi-{{ node_type['name'] }}")
|
||||
| {% if node_type_name == 'group_texture' %}
|
||||
| Texture Folder
|
||||
| {% elif node_type_name == 'group_hdri' %}
|
||||
| HDRi Folder
|
||||
| {% else %}
|
||||
| {{ node_type_name }}
|
||||
span.text-capitalize
|
||||
|{{ node_type_name }}
|
||||
| {% endif %}
|
||||
| {% endif %}
|
||||
| {% endfor %}
|
||||
|
52
src/templates/_macros/_asset_list_item.pug
Normal file
52
src/templates/_macros/_asset_list_item.pug
Normal 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 %}
|
@ -28,15 +28,15 @@
|
||||
span Add files...
|
||||
input(type='file', name='file', multiple='')
|
||||
|
||||
button.btn.btn-primary.start(type='submit')
|
||||
button.btn.btn-outline-primary.start(type='submit')
|
||||
i.pi-upload
|
||||
span Start upload
|
||||
span Start Upload
|
||||
|
||||
button.btn.btn-warning.cancel(type='reset')
|
||||
button.btn.btn-outline-warning.cancel(type='reset')
|
||||
i.pi-cancel
|
||||
span Cancel upload
|
||||
span Cancel Upload
|
||||
|
||||
button.btn.btn-danger.delete(type='button')
|
||||
button.btn.btn-outline-danger.delete(type='button')
|
||||
i.pi-trash
|
||||
span Delete
|
||||
|
||||
|
@ -23,7 +23,7 @@ script#template-upload(type="text/x-tmpl").
|
||||
</button>
|
||||
{% } %}
|
||||
{% if (!i) { %}
|
||||
<button class="btn btn-warning cancel">
|
||||
<button class="btn btn-outline-secondary cancel">
|
||||
<i class="ion-close-round"></i>
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
@ -61,7 +61,7 @@ script#template-download(type="text/x-tmpl").
|
||||
</td>
|
||||
<td>
|
||||
{% if (file.deleteUrl) { %}
|
||||
<button class="btn btn-danger delete" data-type="{%=file.deleteType%}" data-url="{%=file.deleteUrl%}"{% if (file.deleteWithCredentials) { %} data-xhr-fields='{"withCredentials":true}'{% } %}>
|
||||
<button class="btn btn-outline-danger delete" data-type="{%=file.deleteType%}" data-url="{%=file.deleteUrl%}"{% if (file.deleteWithCredentials) { %} data-xhr-fields='{"withCredentials":true}'{% } %}>
|
||||
<i class="ion-trash-b"></i>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
@ -71,7 +71,7 @@ script#template-download(type="text/x-tmpl").
|
||||
Create
|
||||
</div>
|
||||
{% } else { %}
|
||||
<button class="btn btn-warning cancel">
|
||||
<button class="btn btn-outline-secondary cancel">
|
||||
<i class="ion-close-round"></i>
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
|
@ -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 %}
|
@ -9,5 +9,5 @@
|
||||
.modal-body
|
||||
| ...
|
||||
.modal-footer
|
||||
button.btn.btn-default(type='button', data-dismiss='modal') Close
|
||||
button.btn.btn-outline-secondary(type='button', data-dismiss='modal') Close
|
||||
button.btn.btn-primary(type='button') Save changes
|
||||
|
@ -29,32 +29,20 @@ html(lang="en")
|
||||
meta(name="twitter:image", content="")
|
||||
| {% endblock %}
|
||||
|
||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery-3.1.0.min.js')}}")
|
||||
script(src="{{ url_for('static_pillar', filename='assets/js/tutti.min.js') }}")
|
||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.typeahead-0.11.1.min.js')}}")
|
||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/js.cookie-2.0.3.min.js')}}")
|
||||
| {% if current_user.is_authenticated %}
|
||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/clipboard.min.js')}}")
|
||||
| {% endif %}
|
||||
|
||||
script.
|
||||
|
||||
!function(e){"use strict";e.loadCSS=function(t,n,o){var r,i=e.document,l=i.createElement("link");if(n)r=n;else{var d=(i.body||i.getElementsByTagName("head")[0]).childNodes;r=d[d.length-1]}var a=i.styleSheets;l.rel="stylesheet",l.href=t,l.media="only x",r.parentNode.insertBefore(l,n?r:r.nextSibling);var f=function(e){for(var t=l.href,n=a.length;n--;)if(a[n].href===t)return e();setTimeout(function(){f(e)})};return l.onloadcssdefined=f,f(function(){l.media=o||"all"}),l},"undefined"!=typeof module&&(module.exports=e.loadCSS)}(this);
|
||||
|
||||
loadCSS( "//fonts.googleapis.com/css?family=Roboto:300,400" );
|
||||
|
||||
script(src="{{ url_for('static_pillar', filename='assets/js/markdown.min.js') }}")
|
||||
script(src="{{ url_for('static_pillar', filename='assets/js/tutti.min.js') }}")
|
||||
|
||||
link(href="{{ url_for('static', filename='assets/img/favicon.png') }}", rel="shortcut icon")
|
||||
link(href="{{ url_for('static', filename='assets/img/apple-touch-icon-precomposed.png') }}", rel="icon apple-touch-icon-precomposed", sizes="192x192")
|
||||
|
||||
link(href="{{ url_for('static_pillar', filename='assets/css/vendor/bootstrap.min.css') }}", rel="stylesheet")
|
||||
|
||||
| {% block head %}{% endblock %}
|
||||
|
||||
| {% block css %}
|
||||
link(href="{{ url_for('static_pillar', filename='assets/css/font-pillar.css') }}", rel="stylesheet")
|
||||
link(href="{{ url_for('static_pillar', filename='assets/css/base.css') }}", rel="stylesheet")
|
||||
| {% if title == 'blog' %}
|
||||
link(href="{{ url_for('static_pillar', filename='assets/css/blog.css') }}", rel="stylesheet")
|
||||
| {% else %}
|
||||
@ -66,10 +54,9 @@ html(lang="en")
|
||||
| {% if not title %}{% set title="default" %}{% endif %}
|
||||
|
||||
body(class="{{ title }}")
|
||||
.container-page
|
||||
.page-content
|
||||
.page-body
|
||||
| {% block body %}{% endblock %}
|
||||
.page-content
|
||||
.page-body
|
||||
| {% block body %}{% endblock %}
|
||||
|
||||
| {% block footer_container %}
|
||||
#footer-container
|
||||
@ -84,8 +71,6 @@ html(lang="en")
|
||||
| {% endblock footer %}
|
||||
| {% endblock footer_container%}
|
||||
|
||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.bootstrap-3.3.7.min.js') }}")
|
||||
|
||||
| {% if current_user.is_authenticated %}
|
||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.typewatch-3.0.0.min.js') }}")
|
||||
script.
|
||||
|
@ -1,10 +1,11 @@
|
||||
| {% if current_user.is_authenticated %}
|
||||
|
||||
li.nav-notifications
|
||||
a.navbar-item#notifications-toggle(
|
||||
title="Notifications",
|
||||
data-toggle="tooltip",
|
||||
data-placement="bottom")
|
||||
li.nav-notifications.nav-item
|
||||
a.nav-link.px-2(
|
||||
id="notifications-toggle",
|
||||
title="Notifications",
|
||||
data-toggle="tooltip",
|
||||
data-placement="bottom")
|
||||
i.pi-notifications-none.nav-notifications-icon
|
||||
span#notifications-count
|
||||
span
|
||||
|
@ -1,7 +1,7 @@
|
||||
| {% block menu_body %}
|
||||
| {% if current_user.is_authenticated %}
|
||||
|
||||
li(class="dropdown")
|
||||
li.dropdown
|
||||
| {% block menu_avatar %}
|
||||
a.navbar-item.dropdown-toggle(href="#", data-toggle="dropdown", title="{{ current_user.email }}")
|
||||
img.gravatar(
|
||||
@ -9,58 +9,36 @@ li(class="dropdown")
|
||||
alt="Avatar")
|
||||
| {% endblock menu_avatar %}
|
||||
|
||||
ul.dropdown-menu
|
||||
ul.dropdown-menu.dropdown-menu-right
|
||||
| {% if not current_user.has_role('protected') %}
|
||||
| {% block menu_list %}
|
||||
li
|
||||
a.navbar-item(
|
||||
href="{{ url_for('projects.home_project') }}"
|
||||
title="Home")
|
||||
i.pi-home
|
||||
| Home
|
||||
|
||||
li
|
||||
a.navbar-item(
|
||||
href="{{ url_for('projects.index') }}"
|
||||
title="My Projects")
|
||||
i.pi-star
|
||||
| My Projects
|
||||
|
||||
| {% if current_user.has_organizations() %}
|
||||
li
|
||||
a.navbar-item(
|
||||
href="{{ url_for('pillar.web.organizations.index') }}"
|
||||
title="My Organizations")
|
||||
i.pi-users
|
||||
| My Organizations
|
||||
| {% endif %}
|
||||
|
||||
li
|
||||
a.navbar-item(
|
||||
href="{{ url_for('settings.profile') }}"
|
||||
title="Settings")
|
||||
i.pi-cog
|
||||
| Settings
|
||||
a.navbar-item.px-2(
|
||||
href="{{ url_for('settings.profile') }}"
|
||||
title="Settings")
|
||||
| #[i.pi-cog] Settings
|
||||
|
||||
| {% endblock menu_list %}
|
||||
|
||||
li.divider(role="separator")
|
||||
li.dropdown-divider(role="separator")
|
||||
| {% endif %}
|
||||
|
||||
li
|
||||
a.navbar-item(
|
||||
href="{{ url_for('users.logout') }}")
|
||||
href="{{ url_for('users.logout') }}")
|
||||
i.pi-log-out(title="Log Out")
|
||||
| Log out
|
||||
a.navbar-item.subitem(
|
||||
href="{{ url_for('users.switch') }}")
|
||||
a.navbar-item.subitem.pt-0(
|
||||
href="{{ url_for('users.switch') }}")
|
||||
i.pi-blank
|
||||
| Not {{ current_user.full_name }}?
|
||||
|
||||
| {% else %}
|
||||
|
||||
li.nav-item-sign-in
|
||||
a.navbar-item(href="{{ url_for('users.login') }}")
|
||||
| Log in
|
||||
li.pr-1
|
||||
a.btn.btn-sm.btn-outline-primary.px-3(
|
||||
href="{{ url_for('users.login') }}")
|
||||
| Log In
|
||||
| {% endif %}
|
||||
| {% endblock menu_body %}
|
||||
|
73
src/templates/mixins/components.pug
Normal file
73
src/templates/mixins/components.pug
Normal 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
|
||||
|
@ -5,21 +5,21 @@ section.node-preview-forbidden
|
||||
|
||||
div
|
||||
p Available to Blender Cloud subscribers
|
||||
hr
|
||||
hr.bg-white
|
||||
| {% if current_user.has_cap('can-renew-subscription') %}
|
||||
p
|
||||
small You have a subscription, it just needs to be renewed.
|
||||
a.btn(href="/renew")
|
||||
a.btn.btn-light(href="/renew")
|
||||
| #[i.pi-heart] Renew Subscription
|
||||
| {% else %}
|
||||
p
|
||||
small Support Blender and get awesome stuff!
|
||||
a.btn(href="{{ url_for('cloud.join') }}")
|
||||
a.btn.btn-light(href="{{ url_for('cloud.join') }}")
|
||||
| #[i.pi-heart] Get a Subscription
|
||||
| {% endif %}
|
||||
|
||||
| {% if current_user.is_anonymous %}
|
||||
p(style="margin-top: 15px")
|
||||
small
|
||||
a(href="{{ url_for('users.login') }}") Already a subscriber? Log in
|
||||
a.text-white(href="{{ url_for('users.login') }}") Already a subscriber? Log in
|
||||
| {% endif %}
|
||||
|
@ -33,8 +33,8 @@ script(type="text/javascript").
|
||||
} else if (node_type === 'group_hdri') {
|
||||
node_type_str = 'HDRi Folder';
|
||||
}
|
||||
$('a', '.button-edit').html('<i class="pi-edit button-edit-icon"></i> Edit ' + node_type_str);
|
||||
$('a', '.button-delete').html('<i class="pi-trash button-delete-icon"></i>Delete ' + node_type_str);
|
||||
|
||||
$('a', '.button-delete').html('<i class="pr-2 pi-trash button-delete-icon"></i>Delete ' + node_type_str);
|
||||
|
||||
{% if parent %}
|
||||
ProjectUtils.setProjectAttributes({parentNodeId: '{{parent._id}}'});
|
||||
@ -61,7 +61,7 @@ script(type="text/javascript").
|
||||
}
|
||||
|
||||
{% if node.has_method('PUT') %}
|
||||
$('.project-mode-view').show();
|
||||
$('.project-mode-view').displayAs('inline-block');
|
||||
{% else %}
|
||||
$('.project-mode-view').hide();
|
||||
{% endif %}
|
||||
@ -114,7 +114,6 @@ script(type="text/javascript").
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$(page_overlay).find('.nav-prev').click(function(e){
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
@ -133,10 +132,6 @@ script(type="text/javascript").
|
||||
$(this).removeClass('active').hide().html();
|
||||
});
|
||||
|
||||
if (typeof $().popover != 'undefined'){
|
||||
$('#asset-license').popover();
|
||||
}
|
||||
|
||||
{% endif %}
|
||||
|
||||
var $content_type = $(".js-type");
|
||||
|
@ -23,7 +23,7 @@ section.node-preview.video
|
||||
|
||||
| {% block node_download %}
|
||||
| {% if node.file_variations %}
|
||||
button.btn.btn-default.dropdown-toggle(
|
||||
button.btn.btn-outline-primary.dropdown-toggle.px-3(
|
||||
type="button",
|
||||
data-toggle="dropdown",
|
||||
aria-haspopup="true",
|
||||
@ -32,7 +32,7 @@ button.btn.btn-default.dropdown-toggle(
|
||||
| Download
|
||||
i.pi-angle-down.icon-dropdown-menu
|
||||
|
||||
ul.dropdown-menu
|
||||
ul.dropdown-menu.dropdown-menu-right
|
||||
| {% for variation in node.file_variations %}
|
||||
li
|
||||
a(href="{{ variation.link }}",
|
||||
@ -52,25 +52,49 @@ script(type="text/javascript").
|
||||
{% if node.video_sources %}
|
||||
|
||||
var videoPlayer = document.getElementById('videoplayer');
|
||||
|
||||
var options = {
|
||||
controlBar: {
|
||||
volumePanel: { inline: false }
|
||||
}
|
||||
},
|
||||
playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 4]
|
||||
};
|
||||
|
||||
videojs.registerPlugin('analytics', function() {
|
||||
videojs(videoPlayer, options).ready(function() {
|
||||
this.ga({
|
||||
'eventLabel' : '{{ node._id }} - {{ node.name }}',
|
||||
'eventCategory' : '{{ node.project }}',
|
||||
'eventsToTrack' : ['start', 'error', 'percentsPlayed']
|
||||
});
|
||||
|
||||
this.hotkeys({
|
||||
enableVolumeScroll: false,
|
||||
customKeys: {
|
||||
KeyL: {
|
||||
key: function(event) {
|
||||
return (event.which === 76);
|
||||
},
|
||||
handler: function(player, options, event) {
|
||||
videoPlayerToggleLoop(videoPlayer, videoPlayerLoopButton);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.rememberVolumePlugin();
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
let fetch_progress_url = '{{ url_for("users_api.get_video_progress", video_id=node._id) }}';
|
||||
let report_url = '{{ url_for("users_api.set_video_progress", video_id=node._id) }}';
|
||||
|
||||
this.progressPlugin({
|
||||
'report_url': report_url,
|
||||
'fetch_progress_url': fetch_progress_url,
|
||||
});
|
||||
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
videojs(videoPlayer, options).ready(function() {
|
||||
this.hotkeys();
|
||||
});
|
||||
|
||||
// Generic utility to add-buttons to the player.
|
||||
function addVideoPlayerButton(data) {
|
||||
|
||||
var controlBar,
|
||||
@ -89,6 +113,7 @@ script(type="text/javascript").
|
||||
return newButton;
|
||||
}
|
||||
|
||||
// Video loop stuff. TODO: Move it to video_plugins.js
|
||||
var videoPlayerLoopButton = addVideoPlayerButton({
|
||||
player: videoPlayer,
|
||||
class: 'vjs-loop-button',
|
||||
@ -96,15 +121,18 @@ script(type="text/javascript").
|
||||
title: 'Loop'
|
||||
});
|
||||
|
||||
videoPlayerLoopButton.onclick = function() {
|
||||
function videoPlayerToggleLoop(videoPlayer, videoPlayerLoopButton) {
|
||||
if (videoPlayer.loop){
|
||||
videoPlayer.loop = false;
|
||||
$(this).removeClass('vjs-control-active');
|
||||
|
||||
$(videoPlayerLoopButton).removeClass('vjs-control-active');
|
||||
} else {
|
||||
videoPlayer.loop = true;
|
||||
$(this).addClass('vjs-control-active');
|
||||
$(videoPlayerLoopButton).addClass('vjs-control-active');
|
||||
}
|
||||
}
|
||||
|
||||
videoPlayerLoopButton.onclick = function() {
|
||||
videoPlayerToggleLoop(videoPlayer, videoPlayerLoopButton);
|
||||
};
|
||||
|
||||
{% endif %} // if node.video_sources
|
||||
|
@ -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
Loading…
x
Reference in New Issue
Block a user