Compare commits
131 Commits
wip-redesi
...
wip-refact
Author | SHA1 | Date | |
---|---|---|---|
77e3c476f0 | |||
842ddaeab0 | |||
85e5cb4f71 | |||
6648f8d074 | |||
a5bc36b1cf | |||
e56b3ec61f | |||
9624f6bd76 | |||
4e5a53a19b | |||
fbc7c0fce7 | |||
bb483e72aa | |||
baf27fa560 | |||
845ba953cb | |||
e5b7905a5c | |||
88c0ef0e7c | |||
f8d992400e | |||
263d68071e | |||
0f7f7d5a66 | |||
6b29c70212 | |||
07670dce96 | |||
fe288b1cc2 | |||
2e9555e160 | |||
b0311af6b5 | |||
35a22cab4b | |||
0055633732 | |||
78b186c8e4 | |||
232321cc2c | |||
a6d662b690 | |||
32c7ffbc99 | |||
cfcc629b61 | |||
8ea0310956 | |||
c1958d2da7 | |||
030c5494a8 | |||
462f31406a | |||
1a1f67cf00 | |||
8d5bdf04aa | |||
9a9d15ce47 | |||
c795015a3c | |||
afda0062f5 | |||
a97c8ffc93 | |||
c5fa6b9535 | |||
2be41a7145 | |||
e8fb77c39b | |||
40933d51cf | |||
9a9ca1bf8b | |||
0983474e76 | |||
6bcce87bb9 | |||
1401a6168f | |||
85eab0c6cb | |||
a753637e70 | |||
f87c7a25df | |||
3ae16d7750 | |||
c546dd2881 | |||
48df0583ab | |||
094d15116e | |||
534d06ca8f | |||
df078b395d | |||
5df92ca4cf | |||
ecace8c55b | |||
bcacdfb7ea | |||
d7fd90ded1 | |||
b9268337c3 | |||
9b62daec74 | |||
5cc5698477 | |||
00ba98d279 | |||
e818c92d4e | |||
612862c048 | |||
6b3f025e16 | |||
8a90cd00e9 | |||
17a69b973e | |||
8380270128 | |||
35225a189d | |||
be98a95fc0 | |||
95c1f913c6 | |||
9bcd6cec89 | |||
4532c1ea39 | |||
e19dd27099 | |||
f54e56bad8 | |||
eb851ce6e1 | |||
586d9c0d3b | |||
ac23c7b00b | |||
811edc5a2a | |||
cb95bf989a | |||
e4fa32b8e4 | |||
08bf63c2ee | |||
bc16bb6e56 | |||
0fcafddbd1 | |||
f29e01c78e | |||
2698be3e12 | |||
3f8e0396cf | |||
05c488c484 | |||
40c19a3cb0 | |||
a67527d6af | |||
791906521f | |||
2ad5b20880 | |||
f6fd9228e5 | |||
e9f303f330 | |||
00a7406a1e | |||
82aa521b5f | |||
f7220924bc | |||
595bb48741 | |||
1c430044b9 | |||
73bc084417 | |||
37ca803162 | |||
939bb97f13 | |||
2c40665271 | |||
e8123b7839 | |||
6d6a40b8c0 | |||
efd345ec46 | |||
d655d2b749 | |||
a58e616769 | |||
a8a7166e78 | |||
1649591d75 | |||
9389fef8ba | |||
6737aa1123 | |||
469f24d113 | |||
8a0f582a80 | |||
559e212c55 | |||
61278730c6 | |||
0fdcbc3947 | |||
8dc3296bd5 | |||
a699138fd6 | |||
7da741f354 | |||
41369d134c | |||
61ed083218 | |||
46777f7f8c | |||
ef94c68177 | |||
aaf452e18b | |||
c607eaf23d | |||
baa77a7de5 | |||
c83a1a21b8 | |||
549cf0a3e8 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -12,6 +12,7 @@ config_local.py
|
|||||||
|
|
||||||
/build
|
/build
|
||||||
/.cache
|
/.cache
|
||||||
|
/.pytest_cache/
|
||||||
/*.egg-info/
|
/*.egg-info/
|
||||||
profile.stats
|
profile.stats
|
||||||
/dump/
|
/dump/
|
||||||
@@ -26,6 +27,7 @@ profile.stats
|
|||||||
|
|
||||||
pillar/web/static/assets/css/*.css
|
pillar/web/static/assets/css/*.css
|
||||||
pillar/web/static/assets/js/*.min.js
|
pillar/web/static/assets/js/*.min.js
|
||||||
|
pillar/web/static/assets/js/vendor/video.min.js
|
||||||
pillar/web/static/storage/
|
pillar/web/static/storage/
|
||||||
pillar/web/static/uploads/
|
pillar/web/static/uploads/
|
||||||
pillar/web/templates/
|
pillar/web/templates/
|
||||||
|
63
gulpfile.js
63
gulpfile.js
@@ -16,7 +16,7 @@ var uglify = require('gulp-uglify-es').default;
|
|||||||
|
|
||||||
var enabled = {
|
var enabled = {
|
||||||
uglify: argv.production,
|
uglify: argv.production,
|
||||||
maps: argv.production,
|
maps: !argv.production,
|
||||||
failCheck: !argv.production,
|
failCheck: !argv.production,
|
||||||
prettyPug: !argv.production,
|
prettyPug: !argv.production,
|
||||||
cachify: !argv.production,
|
cachify: !argv.production,
|
||||||
@@ -32,6 +32,7 @@ var destination = {
|
|||||||
|
|
||||||
var source = {
|
var source = {
|
||||||
bootstrap: 'node_modules/bootstrap/',
|
bootstrap: 'node_modules/bootstrap/',
|
||||||
|
jquery: 'node_modules/jquery/',
|
||||||
popper: 'node_modules/popper.js/'
|
popper: 'node_modules/popper.js/'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,10 +79,23 @@ gulp.task('scripts', function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
/* Collection of scripts in src/scripts/tutti/ to merge into tutti.min.js */
|
/* Collection of scripts in src/scripts/tutti/ to merge into tutti.min.js
|
||||||
/* Since it's always loaded, it's only for functions that we want site-wide */
|
* Since it's always loaded, it's only for functions that we want site-wide.
|
||||||
|
* It also includes jQuery and Bootstrap (and its dependency popper), since
|
||||||
|
* the site doesn't work without it anyway.*/
|
||||||
gulp.task('scripts_concat_tutti', function() {
|
gulp.task('scripts_concat_tutti', function() {
|
||||||
gulp.src('src/scripts/tutti/**/*.js')
|
|
||||||
|
toUglify = [
|
||||||
|
source.jquery + 'dist/jquery.min.js',
|
||||||
|
source.popper + 'dist/umd/popper.min.js',
|
||||||
|
source.bootstrap + 'js/dist/index.js',
|
||||||
|
source.bootstrap + 'js/dist/util.js',
|
||||||
|
source.bootstrap + 'js/dist/tooltip.js',
|
||||||
|
source.bootstrap + 'js/dist/dropdown.js',
|
||||||
|
'src/scripts/tutti/**/*.js'
|
||||||
|
];
|
||||||
|
|
||||||
|
gulp.src(toUglify)
|
||||||
.pipe(gulpif(enabled.failCheck, plumber()))
|
.pipe(gulpif(enabled.failCheck, plumber()))
|
||||||
.pipe(gulpif(enabled.maps, sourcemaps.init()))
|
.pipe(gulpif(enabled.maps, sourcemaps.init()))
|
||||||
.pipe(concat("tutti.min.js"))
|
.pipe(concat("tutti.min.js"))
|
||||||
@@ -92,39 +106,17 @@ gulp.task('scripts_concat_tutti', function() {
|
|||||||
.pipe(gulpif(argv.livereload, livereload()));
|
.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(gulpif(enabled.chmod, 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) {
|
||||||
|
|
||||||
// Combine all needed Bootstrap JavaScript into a single file.
|
let toMove = [
|
||||||
gulp.task('scripts_concat_bootstrap', function() {
|
'node_modules/video.js/dist/video.min.js',
|
||||||
|
|
||||||
toUglify = [
|
|
||||||
source.popper + 'dist/umd/popper.min.js',
|
|
||||||
source.bootstrap + 'js/dist/index.js',
|
|
||||||
source.bootstrap + 'js/dist/util.js',
|
|
||||||
source.bootstrap + 'js/dist/tooltip.js',
|
|
||||||
source.bootstrap + 'js/dist/dropdown.js',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
gulp.src(toUglify)
|
gulp.src(toMove)
|
||||||
.pipe(gulpif(enabled.failCheck, plumber()))
|
.pipe(gulp.dest(destination.js + '/vendor/'));
|
||||||
.pipe(gulpif(enabled.maps, sourcemaps.init()))
|
done();
|
||||||
.pipe(concat("bootstrap.min.js"))
|
|
||||||
.pipe(gulpif(enabled.uglify, uglify()))
|
|
||||||
.pipe(gulpif(enabled.maps, sourcemaps.write(".")))
|
|
||||||
.pipe(gulpif(enabled.chmod, chmod(644)))
|
|
||||||
.pipe(gulp.dest(destination.js))
|
|
||||||
.pipe(gulpif(argv.livereload, livereload()));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -139,9 +131,9 @@ gulp.task('watch',function() {
|
|||||||
gulp.watch('src/templates/**/*.pug',['templates']);
|
gulp.watch('src/templates/**/*.pug',['templates']);
|
||||||
gulp.watch('src/scripts/*.js',['scripts']);
|
gulp.watch('src/scripts/*.js',['scripts']);
|
||||||
gulp.watch('src/scripts/tutti/**/*.js',['scripts_concat_tutti']);
|
gulp.watch('src/scripts/tutti/**/*.js',['scripts_concat_tutti']);
|
||||||
gulp.watch('src/scripts/markdown/**/*.js',['scripts_concat_markdown']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// Erases all generated files in output directories.
|
// Erases all generated files in output directories.
|
||||||
gulp.task('cleanup', function() {
|
gulp.task('cleanup', function() {
|
||||||
var paths = [];
|
var paths = [];
|
||||||
@@ -164,6 +156,5 @@ gulp.task('default', tasks.concat([
|
|||||||
'templates',
|
'templates',
|
||||||
'scripts',
|
'scripts',
|
||||||
'scripts_concat_tutti',
|
'scripts_concat_tutti',
|
||||||
'scripts_concat_markdown',
|
'scripts_move_vendor',
|
||||||
'scripts_concat_bootstrap',
|
|
||||||
]));
|
]));
|
||||||
|
3597
package-lock.json
generated
3597
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bootstrap": "^4.1.3",
|
"bootstrap": "^4.1.3",
|
||||||
"jquery": "^3.3.1",
|
"jquery": "^3.3.1",
|
||||||
"popper.js": "^1.14.4"
|
"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.org_manager = pillar.api.organizations.OrgManager()
|
||||||
|
|
||||||
self.before_first_request(self.setup_db_indices)
|
|
||||||
|
|
||||||
# Make CSRF protection available to the application. By default it is
|
# Make CSRF protection available to the application. By default it is
|
||||||
# disabled on all endpoints. More info at WTF_CSRF_CHECK_DEFAULT in config.py
|
# disabled on all endpoints. More info at WTF_CSRF_CHECK_DEFAULT in config.py
|
||||||
self.csrf = CSRFProtect(self)
|
self.csrf = CSRFProtect(self)
|
||||||
@@ -280,7 +278,7 @@ class PillarServer(BlinkerCompatibleEve):
|
|||||||
self.encoding_service_client = Zencoder(self.config['ZENCODER_API_KEY'])
|
self.encoding_service_client = Zencoder(self.config['ZENCODER_API_KEY'])
|
||||||
|
|
||||||
def _config_caching(self):
|
def _config_caching(self):
|
||||||
from flask_cache import Cache
|
from flask_caching import Cache
|
||||||
self.cache = Cache(self)
|
self.cache = Cache(self)
|
||||||
|
|
||||||
def set_languages(self, translations_folder: pathlib.Path):
|
def set_languages(self, translations_folder: pathlib.Path):
|
||||||
@@ -479,10 +477,11 @@ class PillarServer(BlinkerCompatibleEve):
|
|||||||
|
|
||||||
# Pillar-defined Celery task modules:
|
# Pillar-defined Celery task modules:
|
||||||
celery_task_modules = [
|
celery_task_modules = [
|
||||||
'pillar.celery.tasks',
|
'pillar.celery.badges',
|
||||||
'pillar.celery.search_index_tasks',
|
|
||||||
'pillar.celery.file_link_tasks',
|
|
||||||
'pillar.celery.email_tasks',
|
'pillar.celery.email_tasks',
|
||||||
|
'pillar.celery.file_link_tasks',
|
||||||
|
'pillar.celery.search_index_tasks',
|
||||||
|
'pillar.celery.tasks',
|
||||||
]
|
]
|
||||||
|
|
||||||
# Allow Pillar extensions from defining their own Celery tasks.
|
# Allow Pillar extensions from defining their own Celery tasks.
|
||||||
@@ -704,6 +703,8 @@ class PillarServer(BlinkerCompatibleEve):
|
|||||||
def finish_startup(self):
|
def finish_startup(self):
|
||||||
self.log.info('Using MongoDB database %r', self.config['MONGO_DBNAME'])
|
self.log.info('Using MongoDB database %r', self.config['MONGO_DBNAME'])
|
||||||
|
|
||||||
|
with self.app_context():
|
||||||
|
self.setup_db_indices()
|
||||||
self._config_celery()
|
self._config_celery()
|
||||||
|
|
||||||
api.setup_app(self)
|
api.setup_app(self)
|
||||||
@@ -760,6 +761,8 @@ class PillarServer(BlinkerCompatibleEve):
|
|||||||
coll.create_index([('properties.status', pymongo.ASCENDING),
|
coll.create_index([('properties.status', pymongo.ASCENDING),
|
||||||
('node_type', pymongo.ASCENDING),
|
('node_type', pymongo.ASCENDING),
|
||||||
('_created', pymongo.DESCENDING)])
|
('_created', pymongo.DESCENDING)])
|
||||||
|
# Used for asset tags
|
||||||
|
coll.create_index([('properties.tags', pymongo.ASCENDING)])
|
||||||
|
|
||||||
coll = db['projects']
|
coll = db['projects']
|
||||||
# This index is used for statistics, and for fetching public projects.
|
# This index is used for statistics, and for fetching public projects.
|
||||||
|
@@ -6,6 +6,7 @@ with Blender ID.
|
|||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from bson import tz_util
|
from bson import tz_util
|
||||||
@@ -114,13 +115,14 @@ def validate_token(user_id, token, oauth_subclient_id):
|
|||||||
# We only want to accept Blender Cloud tokens.
|
# We only want to accept Blender Cloud tokens.
|
||||||
payload['client_id'] = current_app.config['OAUTH_CREDENTIALS']['blender-id']['id']
|
payload['client_id'] = current_app.config['OAUTH_CREDENTIALS']['blender-id']['id']
|
||||||
|
|
||||||
url = '{0}/u/validate_token'.format(current_app.config['BLENDER_ID_ENDPOINT'])
|
blender_id_endpoint = current_app.config['BLENDER_ID_ENDPOINT']
|
||||||
|
url = urljoin(blender_id_endpoint, 'u/validate_token')
|
||||||
log.debug('POSTing to %r', url)
|
log.debug('POSTing to %r', url)
|
||||||
|
|
||||||
# Retry a few times when POSTing to BlenderID fails.
|
# Retry a few times when POSTing to BlenderID fails.
|
||||||
# Source: http://stackoverflow.com/a/15431343/875379
|
# Source: http://stackoverflow.com/a/15431343/875379
|
||||||
s = requests.Session()
|
s = requests.Session()
|
||||||
s.mount(current_app.config['BLENDER_ID_ENDPOINT'], HTTPAdapter(max_retries=5))
|
s.mount(blender_id_endpoint, HTTPAdapter(max_retries=5))
|
||||||
|
|
||||||
# POST to Blender ID, handling errors as negative verification results.
|
# POST to Blender ID, handling errors as negative verification results.
|
||||||
try:
|
try:
|
||||||
@@ -218,7 +220,7 @@ def fetch_blenderid_user() -> dict:
|
|||||||
|
|
||||||
my_log = log.getChild('fetch_blenderid_user')
|
my_log = log.getChild('fetch_blenderid_user')
|
||||||
|
|
||||||
bid_url = '%s/api/user' % current_app.config['BLENDER_ID_ENDPOINT']
|
bid_url = urljoin(current_app.config['BLENDER_ID_ENDPOINT'], 'api/user')
|
||||||
my_log.debug('Fetching user info from %s', bid_url)
|
my_log.debug('Fetching user info from %s', bid_url)
|
||||||
|
|
||||||
credentials = current_app.config['OAUTH_CREDENTIALS']['blender-id']
|
credentials = current_app.config['OAUTH_CREDENTIALS']['blender-id']
|
||||||
@@ -263,7 +265,7 @@ def setup_app(app, url_prefix):
|
|||||||
def switch_user_url(next_url: str) -> str:
|
def switch_user_url(next_url: str) -> str:
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
base_url = '%s/switch' % current_app.config['BLENDER_ID_ENDPOINT']
|
base_url = urljoin(current_app.config['BLENDER_ID_ENDPOINT'], 'switch')
|
||||||
if next_url:
|
if next_url:
|
||||||
return '%s?next=%s' % (base_url, quote(next_url))
|
return '%s?next=%s' % (base_url, quote(next_url))
|
||||||
return base_url
|
return base_url
|
||||||
|
@@ -1,8 +1,8 @@
|
|||||||
|
import copy
|
||||||
|
from datetime import datetime
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from bson import ObjectId, tz_util
|
from bson import ObjectId, tz_util
|
||||||
from datetime import datetime
|
|
||||||
import cerberus.errors
|
|
||||||
from eve.io.mongo import Validator
|
from eve.io.mongo import Validator
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
|
||||||
@@ -12,6 +12,31 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class ValidateCustomFields(Validator):
|
class ValidateCustomFields(Validator):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# Will be reference to the actual document being validated, so that we can
|
||||||
|
# modify it during validation.
|
||||||
|
self.__real_document = None
|
||||||
|
|
||||||
|
def validate(self, document, *args, **kwargs):
|
||||||
|
# Keep a reference to the actual document, because Cerberus validates copies.
|
||||||
|
self.__real_document = document
|
||||||
|
result = super().validate(document, *args, **kwargs)
|
||||||
|
|
||||||
|
# Store the in-place modified document as self.document, so that Eve's post_internal
|
||||||
|
# can actually pick it up as the validated document. We need to make a copy so that
|
||||||
|
# further modifications (like setting '_etag' etc.) aren't done in-place.
|
||||||
|
self.document = copy.deepcopy(document)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _get_child_validator(self, *args, **kwargs):
|
||||||
|
child = super()._get_child_validator(*args, **kwargs)
|
||||||
|
# Pass along our reference to the actual document.
|
||||||
|
child.__real_document = self.__real_document
|
||||||
|
return child
|
||||||
|
|
||||||
# TODO: split this into a convert_property(property, schema) and call that from this function.
|
# TODO: split this into a convert_property(property, schema) and call that from this function.
|
||||||
def convert_properties(self, properties, node_schema):
|
def convert_properties(self, properties, node_schema):
|
||||||
"""Converts datetime strings and ObjectId strings to actual Python objects."""
|
"""Converts datetime strings and ObjectId strings to actual Python objects."""
|
||||||
@@ -73,6 +98,11 @@ class ValidateCustomFields(Validator):
|
|||||||
dict_property[key] = self.convert_properties(item_prop, item_schema)['item']
|
dict_property[key] = self.convert_properties(item_prop, item_schema)['item']
|
||||||
|
|
||||||
def _validate_valid_properties(self, valid_properties, field, value):
|
def _validate_valid_properties(self, valid_properties, field, value):
|
||||||
|
"""Fake property that triggers node dynamic property validation.
|
||||||
|
|
||||||
|
The rule's arguments are validated against this schema:
|
||||||
|
{'type': 'boolean'}
|
||||||
|
"""
|
||||||
from pillar.api.utils import project_get_node_type
|
from pillar.api.utils import project_get_node_type
|
||||||
|
|
||||||
projects_collection = current_app.data.driver.db['projects']
|
projects_collection = current_app.data.driver.db['projects']
|
||||||
@@ -107,7 +137,8 @@ class ValidateCustomFields(Validator):
|
|||||||
if val:
|
if val:
|
||||||
# This ensures the modifications made by v's coercion rules are
|
# This ensures the modifications made by v's coercion rules are
|
||||||
# visible to this validator's output.
|
# visible to this validator's output.
|
||||||
self.current[field] = v.current
|
# TODO(fsiddi): this no longer works due to Cerberus internal changes.
|
||||||
|
# self.current[field] = v.current
|
||||||
return True
|
return True
|
||||||
|
|
||||||
log.warning('Error validating properties for node %s: %s', self.document, v.errors)
|
log.warning('Error validating properties for node %s: %s', self.document, v.errors)
|
||||||
@@ -118,6 +149,9 @@ class ValidateCustomFields(Validator):
|
|||||||
|
|
||||||
Combine "required_after_creation=True" with "required=False" to allow
|
Combine "required_after_creation=True" with "required=False" to allow
|
||||||
pre-insert hooks to set default values.
|
pre-insert hooks to set default values.
|
||||||
|
|
||||||
|
The rule's arguments are validated against this schema:
|
||||||
|
{'type': 'boolean'}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not required_after_creation:
|
if not required_after_creation:
|
||||||
@@ -125,14 +159,14 @@ class ValidateCustomFields(Validator):
|
|||||||
# validator at all.
|
# validator at all.
|
||||||
return
|
return
|
||||||
|
|
||||||
if self._id is None:
|
if self.document_id is None:
|
||||||
# This is a creation call, in which case this validator shouldn't run.
|
# This is a creation call, in which case this validator shouldn't run.
|
||||||
return
|
return
|
||||||
|
|
||||||
if not value:
|
if not value:
|
||||||
self._error(field, "Value is required once the document was created")
|
self._error(field, "Value is required once the document was created")
|
||||||
|
|
||||||
def _validate_type_iprange(self, field_name: str, value: str):
|
def _validator_iprange(self, field_name: str, value: str):
|
||||||
"""Ensure the field contains a valid IP address.
|
"""Ensure the field contains a valid IP address.
|
||||||
|
|
||||||
Supports both IPv6 and IPv4 ranges. Requires the IPy module.
|
Supports both IPv6 and IPv4 ranges. Requires the IPy module.
|
||||||
@@ -149,40 +183,36 @@ class ValidateCustomFields(Validator):
|
|||||||
if ip.prefixlen() == 0:
|
if ip.prefixlen() == 0:
|
||||||
self._error(field_name, 'Zero-length prefix is not allowed')
|
self._error(field_name, 'Zero-length prefix is not allowed')
|
||||||
|
|
||||||
def _validate_type_binary(self, field_name: str, value: bytes):
|
def _validator_markdown(self, field, value):
|
||||||
"""Add support for binary type.
|
"""Convert MarkDown.
|
||||||
|
|
||||||
This type was actually introduced in Cerberus 1.0, so we can drop
|
|
||||||
support for this once Eve starts using that version (or newer).
|
|
||||||
"""
|
"""
|
||||||
|
my_log = log.getChild('_validator_markdown')
|
||||||
|
|
||||||
if not isinstance(value, (bytes, bytearray)):
|
# Find this field inside the original document
|
||||||
self._error(field_name, f'wrong value type {type(value)}, expected bytes or bytearray')
|
my_subdoc = self._subdoc_in_real_document()
|
||||||
|
if my_subdoc is None:
|
||||||
|
# If self.update==True we are validating an update document, which
|
||||||
|
# may not contain all fields, so then a missing field is fine.
|
||||||
|
if not self.update:
|
||||||
|
self._error(field, f'validator_markdown: unable to find sub-document '
|
||||||
|
f'for path {self.document_path}')
|
||||||
|
return
|
||||||
|
|
||||||
def _validate_coerce(self, coerce, field: str, value):
|
my_log.debug('validating field %r with value %r', field, value)
|
||||||
"""Override Cerberus' _validate_coerce method for richer features.
|
save_to = pillar.markdown.cache_field_name(field)
|
||||||
|
|
||||||
This now supports named coercion functions (available in Cerberus 1.0+)
|
|
||||||
and passes the field name to coercion functions as well.
|
|
||||||
"""
|
|
||||||
if isinstance(coerce, str):
|
|
||||||
coerce = getattr(self, f'_normalize_coerce_{coerce}')
|
|
||||||
|
|
||||||
try:
|
|
||||||
return coerce(field, value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
self._error(field, cerberus.errors.ERROR_COERCION_FAILED.format(field))
|
|
||||||
|
|
||||||
def _normalize_coerce_markdown(self, field: str, value):
|
|
||||||
"""Render Markdown from this field into {field}_html.
|
|
||||||
|
|
||||||
The field name MUST NOT end in `_html`. The Markdown is read from this
|
|
||||||
field and the rendered HTML is written to the field `{field}_html`.
|
|
||||||
"""
|
|
||||||
html = pillar.markdown.markdown(value)
|
html = pillar.markdown.markdown(value)
|
||||||
field_name = pillar.markdown.cache_field_name(field)
|
my_log.debug('saving result to %r in doc with id %s', save_to, id(my_subdoc))
|
||||||
self.current[field_name] = html
|
my_subdoc[save_to] = html
|
||||||
return value
|
|
||||||
|
def _subdoc_in_real_document(self):
|
||||||
|
"""Return a reference to the current sub-document inside the real document.
|
||||||
|
|
||||||
|
This allows modification of the document being validated.
|
||||||
|
"""
|
||||||
|
my_subdoc = getattr(self, 'persisted_document') or self.__real_document
|
||||||
|
for item in self.document_path:
|
||||||
|
my_subdoc = my_subdoc[item]
|
||||||
|
return my_subdoc
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
@@ -190,12 +220,12 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
v = ValidateCustomFields()
|
v = ValidateCustomFields()
|
||||||
v.schema = {
|
v.schema = {
|
||||||
'foo': {'type': 'string', 'coerce': 'markdown'},
|
'foo': {'type': 'string', 'validator': 'markdown'},
|
||||||
'foo_html': {'type': 'string'},
|
'foo_html': {'type': 'string'},
|
||||||
'nested': {
|
'nested': {
|
||||||
'type': 'dict',
|
'type': 'dict',
|
||||||
'schema': {
|
'schema': {
|
||||||
'bar': {'type': 'string', 'coerce': 'markdown'},
|
'bar': {'type': 'string', 'validator': 'markdown'},
|
||||||
'bar_html': {'type': 'string'},
|
'bar_html': {'type': 'string'},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -121,12 +121,43 @@ users_schema = {
|
|||||||
'service': {
|
'service': {
|
||||||
'type': 'dict',
|
'type': 'dict',
|
||||||
'allow_unknown': True,
|
'allow_unknown': True,
|
||||||
|
},
|
||||||
|
|
||||||
|
# Node-specific information for this user.
|
||||||
|
'nodes': {
|
||||||
|
'type': 'dict',
|
||||||
'schema': {
|
'schema': {
|
||||||
'badger': {
|
# Per watched video info about where the user left off, both in time and in percent.
|
||||||
'type': 'list',
|
'view_progress': {
|
||||||
'schema': {'type': 'string'}
|
'type': 'dict',
|
||||||
}
|
# Keyed by Node ID of the video asset. MongoDB doesn't support using
|
||||||
}
|
# ObjectIds as key, so we cast them to string instead.
|
||||||
|
'keyschema': {'type': 'string'},
|
||||||
|
'valueschema': {
|
||||||
|
'type': 'dict',
|
||||||
|
'schema': {
|
||||||
|
'progress_in_sec': {'type': 'float', 'min': 0},
|
||||||
|
'progress_in_percent': {'type': 'integer', 'min': 0, 'max': 100},
|
||||||
|
|
||||||
|
# When the progress was last updated, so we can limit this history to
|
||||||
|
# the last-watched N videos if we want, or show stuff in chrono order.
|
||||||
|
'last_watched': {'type': 'datetime'},
|
||||||
|
|
||||||
|
# True means progress_in_percent = 100, for easy querying
|
||||||
|
'done': {'type': 'boolean', 'default': False},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
'badges': {
|
||||||
|
'type': 'dict',
|
||||||
|
'schema': {
|
||||||
|
'html': {'type': 'string'}, # HTML fetched from Blender ID.
|
||||||
|
'expires': {'type': 'datetime'}, # When we should fetch it again.
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
# Properties defined by extensions. Extensions should use their name (see the
|
# Properties defined by extensions. Extensions should use their name (see the
|
||||||
@@ -155,7 +186,7 @@ organizations_schema = {
|
|||||||
'description': {
|
'description': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'maxlength': 256,
|
'maxlength': 256,
|
||||||
'coerce': 'markdown',
|
'validator': 'markdown',
|
||||||
},
|
},
|
||||||
'_description_html': {'type': 'string'},
|
'_description_html': {'type': 'string'},
|
||||||
'website': {
|
'website': {
|
||||||
@@ -227,7 +258,7 @@ organizations_schema = {
|
|||||||
'start': {'type': 'binary', 'required': True},
|
'start': {'type': 'binary', 'required': True},
|
||||||
'end': {'type': 'binary', 'required': True},
|
'end': {'type': 'binary', 'required': True},
|
||||||
'prefix': {'type': 'integer', 'required': True},
|
'prefix': {'type': 'integer', 'required': True},
|
||||||
'human': {'type': 'iprange', 'required': True},
|
'human': {'type': 'string', 'required': True, 'validator': 'iprange'},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -292,7 +323,7 @@ nodes_schema = {
|
|||||||
},
|
},
|
||||||
'description': {
|
'description': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'coerce': 'markdown',
|
'validator': 'markdown',
|
||||||
},
|
},
|
||||||
'_description_html': {'type': 'string'},
|
'_description_html': {'type': 'string'},
|
||||||
'picture': _file_embedded_schema,
|
'picture': _file_embedded_schema,
|
||||||
@@ -327,7 +358,7 @@ nodes_schema = {
|
|||||||
'properties': {
|
'properties': {
|
||||||
'type': 'dict',
|
'type': 'dict',
|
||||||
'valid_properties': True,
|
'valid_properties': True,
|
||||||
'required': True,
|
'required': True
|
||||||
},
|
},
|
||||||
'permissions': {
|
'permissions': {
|
||||||
'type': 'dict',
|
'type': 'dict',
|
||||||
@@ -345,11 +376,11 @@ tokens_schema = {
|
|||||||
},
|
},
|
||||||
'token': {
|
'token': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'required': False,
|
'required': True,
|
||||||
},
|
},
|
||||||
'token_hashed': {
|
'token_hashed': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'required': True,
|
'required': False,
|
||||||
},
|
},
|
||||||
'expire_time': {
|
'expire_time': {
|
||||||
'type': 'datetime',
|
'type': 'datetime',
|
||||||
@@ -368,6 +399,13 @@ tokens_schema = {
|
|||||||
'type': 'string',
|
'type': 'string',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
# OAuth scopes granted to this token.
|
||||||
|
'oauth_scopes': {
|
||||||
|
'type': 'list',
|
||||||
|
'default': [],
|
||||||
|
'schema': {'type': 'string'},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
files_schema = {
|
files_schema = {
|
||||||
@@ -539,7 +577,7 @@ projects_schema = {
|
|||||||
},
|
},
|
||||||
'description': {
|
'description': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'coerce': 'markdown',
|
'validator': 'markdown',
|
||||||
},
|
},
|
||||||
'_description_html': {'type': 'string'},
|
'_description_html': {'type': 'string'},
|
||||||
# Short summary for the project
|
# Short summary for the project
|
||||||
@@ -833,4 +871,9 @@ UPSET_ON_PUT = False # do not create new document on PUT of non-existant URL.
|
|||||||
X_DOMAINS = '*'
|
X_DOMAINS = '*'
|
||||||
X_ALLOW_CREDENTIALS = True
|
X_ALLOW_CREDENTIALS = True
|
||||||
X_HEADERS = 'Authorization'
|
X_HEADERS = 'Authorization'
|
||||||
XML = False
|
RENDERERS = ['eve.render.JSONRenderer']
|
||||||
|
|
||||||
|
# TODO(Sybren): this is a quick workaround to make /p/{url}/jstree work again.
|
||||||
|
# Apparently Eve is now stricter in checking against MONGO_QUERY_BLACKLIST, and
|
||||||
|
# blocks our use of $regex.
|
||||||
|
MONGO_QUERY_BLACKLIST = ['$where']
|
||||||
|
@@ -94,17 +94,10 @@ def generate_and_store_token(user_id, days=15, prefix=b'') -> dict:
|
|||||||
|
|
||||||
# Use 'xy' as altargs to prevent + and / characters from appearing.
|
# Use 'xy' as altargs to prevent + and / characters from appearing.
|
||||||
# We never have to b64decode the string anyway.
|
# We never have to b64decode the string anyway.
|
||||||
token_bytes = prefix + base64.b64encode(random_bits, altchars=b'xy').strip(b'=')
|
token = prefix + base64.b64encode(random_bits, altchars=b'xy').strip(b'=')
|
||||||
token = token_bytes.decode('ascii')
|
|
||||||
|
|
||||||
token_expiry = utcnow() + datetime.timedelta(days=days)
|
token_expiry = utcnow() + datetime.timedelta(days=days)
|
||||||
token_data = store_token(user_id, token, token_expiry)
|
return store_token(user_id, token.decode('ascii'), token_expiry)
|
||||||
|
|
||||||
# Include the token in the returned document so that it can be stored client-side,
|
|
||||||
# in configuration, etc.
|
|
||||||
token_data['token'] = token
|
|
||||||
|
|
||||||
return token_data
|
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str, salt: typing.Union[str, bytes]) -> str:
|
def hash_password(password: str, salt: typing.Union[str, bytes]) -> str:
|
||||||
|
@@ -12,7 +12,7 @@ ATTACHMENT_SLUG_REGEX = r'[a-zA-Z0-9_\-]+'
|
|||||||
attachments_embedded_schema = {
|
attachments_embedded_schema = {
|
||||||
'type': 'dict',
|
'type': 'dict',
|
||||||
# TODO: will be renamed to 'keyschema' in Cerberus 1.0
|
# TODO: will be renamed to 'keyschema' in Cerberus 1.0
|
||||||
'propertyschema': {
|
'keyschema': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'regex': '^%s$' % ATTACHMENT_SLUG_REGEX,
|
'regex': '^%s$' % ATTACHMENT_SLUG_REGEX,
|
||||||
},
|
},
|
||||||
|
@@ -7,7 +7,7 @@ node_type_comment = {
|
|||||||
'type': 'string',
|
'type': 'string',
|
||||||
'minlength': 5,
|
'minlength': 5,
|
||||||
'required': True,
|
'required': True,
|
||||||
'coerce': 'markdown',
|
'validator': 'markdown',
|
||||||
},
|
},
|
||||||
'_content_html': {'type': 'string'},
|
'_content_html': {'type': 'string'},
|
||||||
'status': {
|
'status': {
|
||||||
|
@@ -3,7 +3,7 @@ node_type_group = {
|
|||||||
'description': 'Folder node type',
|
'description': 'Folder node type',
|
||||||
'parent': ['group', 'project'],
|
'parent': ['group', 'project'],
|
||||||
'dyn_schema': {
|
'dyn_schema': {
|
||||||
# Used for sorting within the context of a group
|
|
||||||
'order': {
|
'order': {
|
||||||
'type': 'integer'
|
'type': 'integer'
|
||||||
},
|
},
|
||||||
@@ -20,7 +20,8 @@ node_type_group = {
|
|||||||
'notes': {
|
'notes': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'maxlength': 256,
|
'maxlength': 256,
|
||||||
},
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
'form_schema': {
|
'form_schema': {
|
||||||
'url': {'visible': False},
|
'url': {'visible': False},
|
||||||
|
@@ -9,7 +9,7 @@ node_type_post = {
|
|||||||
'minlength': 5,
|
'minlength': 5,
|
||||||
'maxlength': 90000,
|
'maxlength': 90000,
|
||||||
'required': True,
|
'required': True,
|
||||||
'coerce': 'markdown',
|
'validator': 'markdown',
|
||||||
},
|
},
|
||||||
'_content_html': {'type': 'string'},
|
'_content_html': {'type': 'string'},
|
||||||
'status': {
|
'status': {
|
||||||
|
@@ -1,16 +1,12 @@
|
|||||||
import base64
|
import base64
|
||||||
import functools
|
|
||||||
import logging
|
import logging
|
||||||
import urllib.parse
|
|
||||||
|
|
||||||
import pymongo.errors
|
import pymongo.errors
|
||||||
import werkzeug.exceptions as wz_exceptions
|
import werkzeug.exceptions as wz_exceptions
|
||||||
from bson import ObjectId
|
|
||||||
from flask import current_app, Blueprint, request
|
from flask import current_app, Blueprint, request
|
||||||
|
|
||||||
from pillar.api.activities import activity_subscribe, activity_object_add
|
from pillar.api.nodes import hooks
|
||||||
from pillar.api.node_types import PILLAR_NAMED_NODE_TYPES
|
from pillar.api.nodes.hooks import short_link_info
|
||||||
from pillar.api.file_storage_backends.gcs import update_file_name
|
|
||||||
from pillar.api.utils import str2id, jsonify
|
from pillar.api.utils import str2id, jsonify
|
||||||
from pillar.api.utils.authorization import check_permissions, require_login
|
from pillar.api.utils.authorization import check_permissions, require_login
|
||||||
|
|
||||||
@@ -19,40 +15,6 @@ blueprint = Blueprint('nodes_api', __name__)
|
|||||||
ROLES_FOR_SHARING = {'subscriber', 'demo'}
|
ROLES_FOR_SHARING = {'subscriber', 'demo'}
|
||||||
|
|
||||||
|
|
||||||
def only_for_node_type_decorator(*required_node_type_names):
|
|
||||||
"""Returns a decorator that checks its first argument's node type.
|
|
||||||
|
|
||||||
If the node type is not of the required node type, returns None,
|
|
||||||
otherwise calls the wrapped function.
|
|
||||||
|
|
||||||
>>> deco = only_for_node_type_decorator('comment')
|
|
||||||
>>> @deco
|
|
||||||
... def handle_comment(node): pass
|
|
||||||
|
|
||||||
>>> deco = only_for_node_type_decorator('comment', 'post')
|
|
||||||
>>> @deco
|
|
||||||
... def handle_comment_or_post(node): pass
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Convert to a set for efficient 'x in required_node_type_names' queries.
|
|
||||||
required_node_type_names = set(required_node_type_names)
|
|
||||||
|
|
||||||
def only_for_node_type(wrapped):
|
|
||||||
@functools.wraps(wrapped)
|
|
||||||
def wrapper(node, *args, **kwargs):
|
|
||||||
if node.get('node_type') not in required_node_type_names:
|
|
||||||
return
|
|
||||||
|
|
||||||
return wrapped(node, *args, **kwargs)
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
only_for_node_type.__doc__ = "Decorator, immediately returns when " \
|
|
||||||
"the first argument is not of type %s." % required_node_type_names
|
|
||||||
return only_for_node_type
|
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route('/<node_id>/share', methods=['GET', 'POST'])
|
@blueprint.route('/<node_id>/share', methods=['GET', 'POST'])
|
||||||
@require_login(require_roles=ROLES_FOR_SHARING)
|
@require_login(require_roles=ROLES_FOR_SHARING)
|
||||||
def share_node(node_id):
|
def share_node(node_id):
|
||||||
@@ -88,6 +50,67 @@ def share_node(node_id):
|
|||||||
return jsonify(short_link_info(short_code), status=status)
|
return jsonify(short_link_info(short_code), status=status)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route('/tagged/')
|
||||||
|
@blueprint.route('/tagged/<tag>')
|
||||||
|
def tagged(tag=''):
|
||||||
|
"""Return all tagged nodes of public projects as JSON."""
|
||||||
|
from pillar.auth import current_user
|
||||||
|
|
||||||
|
# We explicitly register the tagless endpoint to raise a 404, otherwise the PATCH
|
||||||
|
# handler on /api/nodes/<node_id> will return a 405 Method Not Allowed.
|
||||||
|
if not tag:
|
||||||
|
raise wz_exceptions.NotFound()
|
||||||
|
|
||||||
|
# Build the (cached) list of tagged nodes
|
||||||
|
agg_list = _tagged(tag)
|
||||||
|
|
||||||
|
# If the user is anonymous, no more information is needed and we return
|
||||||
|
if current_user.is_anonymous:
|
||||||
|
return jsonify(agg_list)
|
||||||
|
|
||||||
|
# If the user is authenticated, attach view_progress for video assets
|
||||||
|
view_progress = current_user.nodes['view_progress']
|
||||||
|
for node in agg_list:
|
||||||
|
node_id = str(node['_id'])
|
||||||
|
# View progress should be added only for nodes of type 'asset' and
|
||||||
|
# with content_type 'video', only if the video was already in the watched
|
||||||
|
# list for the current user.
|
||||||
|
if node_id in view_progress:
|
||||||
|
node['view_progress'] = view_progress[node_id]
|
||||||
|
|
||||||
|
return jsonify(agg_list)
|
||||||
|
|
||||||
|
|
||||||
|
def _tagged(tag: str):
|
||||||
|
"""Fetch all public nodes with the given tag.
|
||||||
|
|
||||||
|
This function is cached, see setup_app().
|
||||||
|
"""
|
||||||
|
nodes_coll = current_app.db('nodes')
|
||||||
|
agg = nodes_coll.aggregate([
|
||||||
|
{'$match': {'properties.tags': tag,
|
||||||
|
'_deleted': {'$ne': True}}},
|
||||||
|
|
||||||
|
# Only get nodes from public projects. This is done after matching the
|
||||||
|
# tagged nodes, because most likely nobody else will be able to tag
|
||||||
|
# nodes anyway.
|
||||||
|
{'$lookup': {
|
||||||
|
'from': 'projects',
|
||||||
|
'localField': 'project',
|
||||||
|
'foreignField': '_id',
|
||||||
|
'as': '_project',
|
||||||
|
}},
|
||||||
|
{'$match': {'_project.is_private': False}},
|
||||||
|
|
||||||
|
# Don't return the entire project for each node.
|
||||||
|
{'$project': {'_project': False}},
|
||||||
|
|
||||||
|
{'$sort': {'_created': -1}}
|
||||||
|
])
|
||||||
|
|
||||||
|
return list(agg)
|
||||||
|
|
||||||
|
|
||||||
def generate_and_store_short_code(node):
|
def generate_and_store_short_code(node):
|
||||||
nodes_coll = current_app.data.driver.db['nodes']
|
nodes_coll = current_app.data.driver.db['nodes']
|
||||||
node_id = node['_id']
|
node_id = node['_id']
|
||||||
@@ -163,265 +186,35 @@ def create_short_code(node) -> str:
|
|||||||
return short_code
|
return short_code
|
||||||
|
|
||||||
|
|
||||||
def short_link_info(short_code):
|
|
||||||
"""Returns the short link info in a dict."""
|
|
||||||
|
|
||||||
short_link = urllib.parse.urljoin(
|
|
||||||
current_app.config['SHORT_LINK_BASE_URL'], short_code)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'short_code': short_code,
|
|
||||||
'short_link': short_link,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def before_replacing_node(item, original):
|
|
||||||
check_permissions('nodes', original, 'PUT')
|
|
||||||
update_file_name(item)
|
|
||||||
|
|
||||||
|
|
||||||
def after_replacing_node(item, original):
|
|
||||||
"""Push an update to the Algolia index when a node item is updated. If the
|
|
||||||
project is private, prevent public indexing.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pillar.celery import search_index_tasks as index
|
|
||||||
|
|
||||||
projects_collection = current_app.data.driver.db['projects']
|
|
||||||
project = projects_collection.find_one({'_id': item['project']})
|
|
||||||
if project.get('is_private', False):
|
|
||||||
# Skip index updating and return
|
|
||||||
return
|
|
||||||
|
|
||||||
status = item['properties'].get('status', 'unpublished')
|
|
||||||
node_id = str(item['_id'])
|
|
||||||
|
|
||||||
if status == 'published':
|
|
||||||
index.node_save.delay(node_id)
|
|
||||||
else:
|
|
||||||
index.node_delete.delay(node_id)
|
|
||||||
|
|
||||||
|
|
||||||
def before_inserting_nodes(items):
|
|
||||||
"""Before inserting a node in the collection we check if the user is allowed
|
|
||||||
and we append the project id to it.
|
|
||||||
"""
|
|
||||||
from pillar.auth import current_user
|
|
||||||
|
|
||||||
nodes_collection = current_app.data.driver.db['nodes']
|
|
||||||
|
|
||||||
def find_parent_project(node):
|
|
||||||
"""Recursive function that finds the ultimate parent of a node."""
|
|
||||||
if node and 'parent' in node:
|
|
||||||
parent = nodes_collection.find_one({'_id': node['parent']})
|
|
||||||
return find_parent_project(parent)
|
|
||||||
if node:
|
|
||||||
return node
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
for item in items:
|
|
||||||
check_permissions('nodes', item, 'POST')
|
|
||||||
if 'parent' in item and 'project' not in item:
|
|
||||||
parent = nodes_collection.find_one({'_id': item['parent']})
|
|
||||||
project = find_parent_project(parent)
|
|
||||||
if project:
|
|
||||||
item['project'] = project['_id']
|
|
||||||
|
|
||||||
# Default the 'user' property to the current user.
|
|
||||||
item.setdefault('user', current_user.user_id)
|
|
||||||
|
|
||||||
|
|
||||||
def after_inserting_nodes(items):
|
|
||||||
for item in items:
|
|
||||||
# Skip subscriptions for first level items (since the context is not a
|
|
||||||
# node, but a project).
|
|
||||||
# TODO: support should be added for mixed context
|
|
||||||
if 'parent' not in item:
|
|
||||||
return
|
|
||||||
context_object_id = item['parent']
|
|
||||||
if item['node_type'] == 'comment':
|
|
||||||
nodes_collection = current_app.data.driver.db['nodes']
|
|
||||||
parent = nodes_collection.find_one({'_id': item['parent']})
|
|
||||||
# Always subscribe to the parent node
|
|
||||||
activity_subscribe(item['user'], 'node', item['parent'])
|
|
||||||
if parent['node_type'] == 'comment':
|
|
||||||
# If the parent is a comment, we provide its own parent as
|
|
||||||
# context. We do this in order to point the user to an asset
|
|
||||||
# or group when viewing the notification.
|
|
||||||
verb = 'replied'
|
|
||||||
context_object_id = parent['parent']
|
|
||||||
# Subscribe to the parent of the parent comment (post or group)
|
|
||||||
activity_subscribe(item['user'], 'node', parent['parent'])
|
|
||||||
else:
|
|
||||||
activity_subscribe(item['user'], 'node', item['_id'])
|
|
||||||
verb = 'commented'
|
|
||||||
elif item['node_type'] in PILLAR_NAMED_NODE_TYPES:
|
|
||||||
verb = 'posted'
|
|
||||||
activity_subscribe(item['user'], 'node', item['_id'])
|
|
||||||
else:
|
|
||||||
# Don't automatically create activities for non-Pillar node types,
|
|
||||||
# as we don't know what would be a suitable verb (among other things).
|
|
||||||
continue
|
|
||||||
|
|
||||||
activity_object_add(
|
|
||||||
item['user'],
|
|
||||||
verb,
|
|
||||||
'node',
|
|
||||||
item['_id'],
|
|
||||||
'node',
|
|
||||||
context_object_id
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def deduct_content_type(node_doc, original=None):
|
|
||||||
"""Deduct the content type from the attached file, if any."""
|
|
||||||
|
|
||||||
if node_doc['node_type'] != 'asset':
|
|
||||||
log.debug('deduct_content_type: called on node type %r, ignoring', node_doc['node_type'])
|
|
||||||
return
|
|
||||||
|
|
||||||
node_id = node_doc.get('_id')
|
|
||||||
try:
|
|
||||||
file_id = ObjectId(node_doc['properties']['file'])
|
|
||||||
except KeyError:
|
|
||||||
if node_id is None:
|
|
||||||
# Creation of a file-less node is allowed, but updates aren't.
|
|
||||||
return
|
|
||||||
log.warning('deduct_content_type: Asset without properties.file, rejecting.')
|
|
||||||
raise wz_exceptions.UnprocessableEntity('Missing file property for asset node')
|
|
||||||
|
|
||||||
files = current_app.data.driver.db['files']
|
|
||||||
file_doc = files.find_one({'_id': file_id},
|
|
||||||
{'content_type': 1})
|
|
||||||
if not file_doc:
|
|
||||||
log.warning('deduct_content_type: Node %s refers to non-existing file %s, rejecting.',
|
|
||||||
node_id, file_id)
|
|
||||||
raise wz_exceptions.UnprocessableEntity('File property refers to non-existing file')
|
|
||||||
|
|
||||||
# Guess the node content type from the file content type
|
|
||||||
file_type = file_doc['content_type']
|
|
||||||
if file_type.startswith('video/'):
|
|
||||||
content_type = 'video'
|
|
||||||
elif file_type.startswith('image/'):
|
|
||||||
content_type = 'image'
|
|
||||||
else:
|
|
||||||
content_type = 'file'
|
|
||||||
|
|
||||||
node_doc['properties']['content_type'] = content_type
|
|
||||||
|
|
||||||
|
|
||||||
def nodes_deduct_content_type(nodes):
|
|
||||||
for node in nodes:
|
|
||||||
deduct_content_type(node)
|
|
||||||
|
|
||||||
|
|
||||||
def before_returning_node(node):
|
|
||||||
# Run validation process, since GET on nodes entry point is public
|
|
||||||
check_permissions('nodes', node, 'GET', append_allowed_methods=True)
|
|
||||||
|
|
||||||
# Embed short_link_info if the node has a short_code.
|
|
||||||
short_code = node.get('short_code')
|
|
||||||
if short_code:
|
|
||||||
node['short_link'] = short_link_info(short_code)['short_link']
|
|
||||||
|
|
||||||
|
|
||||||
def before_returning_nodes(nodes):
|
|
||||||
for node in nodes['_items']:
|
|
||||||
before_returning_node(node)
|
|
||||||
|
|
||||||
|
|
||||||
def node_set_default_picture(node, original=None):
|
|
||||||
"""Uses the image of an image asset or colour map of texture node as picture."""
|
|
||||||
|
|
||||||
if node.get('picture'):
|
|
||||||
log.debug('Node %s already has a picture, not overriding', node.get('_id'))
|
|
||||||
return
|
|
||||||
|
|
||||||
node_type = node.get('node_type')
|
|
||||||
props = node.get('properties', {})
|
|
||||||
content = props.get('content_type')
|
|
||||||
|
|
||||||
if node_type == 'asset' and content == 'image':
|
|
||||||
image_file_id = props.get('file')
|
|
||||||
elif node_type == 'texture':
|
|
||||||
# Find the colour map, defaulting to the first image map available.
|
|
||||||
image_file_id = None
|
|
||||||
for image in props.get('files', []):
|
|
||||||
if image_file_id is None or image.get('map_type') == 'color':
|
|
||||||
image_file_id = image.get('file')
|
|
||||||
else:
|
|
||||||
log.debug('Not setting default picture on node type %s content type %s',
|
|
||||||
node_type, content)
|
|
||||||
return
|
|
||||||
|
|
||||||
if image_file_id is None:
|
|
||||||
log.debug('Nothing to set the picture to.')
|
|
||||||
return
|
|
||||||
|
|
||||||
log.debug('Setting default picture for node %s to %s', node.get('_id'), image_file_id)
|
|
||||||
node['picture'] = image_file_id
|
|
||||||
|
|
||||||
|
|
||||||
def nodes_set_default_picture(nodes):
|
|
||||||
for node in nodes:
|
|
||||||
node_set_default_picture(node)
|
|
||||||
|
|
||||||
|
|
||||||
def before_deleting_node(node: dict):
|
|
||||||
check_permissions('nodes', node, 'DELETE')
|
|
||||||
|
|
||||||
|
|
||||||
def after_deleting_node(item):
|
|
||||||
from pillar.celery import search_index_tasks as index
|
|
||||||
index.node_delete.delay(str(item['_id']))
|
|
||||||
|
|
||||||
|
|
||||||
only_for_textures = only_for_node_type_decorator('texture')
|
|
||||||
|
|
||||||
|
|
||||||
@only_for_textures
|
|
||||||
def texture_sort_files(node, original=None):
|
|
||||||
"""Sort files alphabetically by map type, with colour map first."""
|
|
||||||
|
|
||||||
try:
|
|
||||||
files = node['properties']['files']
|
|
||||||
except KeyError:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Sort the map types alphabetically, ensuring 'color' comes first.
|
|
||||||
as_dict = {f['map_type']: f for f in files}
|
|
||||||
types = sorted(as_dict.keys(), key=lambda k: '\0' if k == 'color' else k)
|
|
||||||
node['properties']['files'] = [as_dict[map_type] for map_type in types]
|
|
||||||
|
|
||||||
|
|
||||||
def textures_sort_files(nodes):
|
|
||||||
for node in nodes:
|
|
||||||
texture_sort_files(node)
|
|
||||||
|
|
||||||
|
|
||||||
def setup_app(app, url_prefix):
|
def setup_app(app, url_prefix):
|
||||||
|
global _tagged
|
||||||
|
|
||||||
|
cached = app.cache.memoize(timeout=300)
|
||||||
|
_tagged = cached(_tagged)
|
||||||
|
|
||||||
from . import patch
|
from . import patch
|
||||||
patch.setup_app(app, url_prefix=url_prefix)
|
patch.setup_app(app, url_prefix=url_prefix)
|
||||||
|
|
||||||
app.on_fetched_item_nodes += before_returning_node
|
app.on_fetched_item_nodes += hooks.before_returning_node
|
||||||
app.on_fetched_resource_nodes += before_returning_nodes
|
app.on_fetched_resource_nodes += hooks.before_returning_nodes
|
||||||
|
|
||||||
app.on_replace_nodes += before_replacing_node
|
app.on_replace_nodes += hooks.before_replacing_node
|
||||||
app.on_replace_nodes += texture_sort_files
|
app.on_replace_nodes += hooks.parse_markdown
|
||||||
app.on_replace_nodes += deduct_content_type
|
app.on_replace_nodes += hooks.texture_sort_files
|
||||||
app.on_replace_nodes += node_set_default_picture
|
app.on_replace_nodes += hooks.deduct_content_type
|
||||||
app.on_replaced_nodes += after_replacing_node
|
app.on_replace_nodes += hooks.node_set_default_picture
|
||||||
|
app.on_replaced_nodes += hooks.after_replacing_node
|
||||||
|
|
||||||
app.on_insert_nodes += before_inserting_nodes
|
app.on_insert_nodes += hooks.before_inserting_nodes
|
||||||
app.on_insert_nodes += nodes_deduct_content_type
|
app.on_insert_nodes += hooks.parse_markdowns
|
||||||
app.on_insert_nodes += nodes_set_default_picture
|
app.on_insert_nodes += hooks.nodes_deduct_content_type
|
||||||
app.on_insert_nodes += textures_sort_files
|
app.on_insert_nodes += hooks.nodes_set_default_picture
|
||||||
app.on_inserted_nodes += after_inserting_nodes
|
app.on_insert_nodes += hooks.textures_sort_files
|
||||||
|
app.on_inserted_nodes += hooks.after_inserting_nodes
|
||||||
|
|
||||||
app.on_update_nodes += texture_sort_files
|
app.on_update_nodes += hooks.texture_sort_files
|
||||||
|
|
||||||
app.on_delete_item_nodes += before_deleting_node
|
app.on_delete_item_nodes += hooks.before_deleting_node
|
||||||
app.on_deleted_item_nodes += after_deleting_node
|
app.on_deleted_item_nodes += hooks.after_deleting_node
|
||||||
|
|
||||||
app.register_api_blueprint(blueprint, url_prefix=url_prefix)
|
app.register_api_blueprint(blueprint, url_prefix=url_prefix)
|
||||||
|
325
pillar/api/nodes/hooks.py
Normal file
325
pillar/api/nodes/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,
|
||||||
|
}
|
@@ -1,7 +1,7 @@
|
|||||||
"""Code for moving around nodes."""
|
"""Code for moving around nodes."""
|
||||||
|
|
||||||
import attr
|
import attr
|
||||||
import flask_pymongo.wrappers
|
import pymongo.database
|
||||||
from bson import ObjectId
|
from bson import ObjectId
|
||||||
|
|
||||||
from pillar import attrs_extra
|
from pillar import attrs_extra
|
||||||
@@ -10,7 +10,7 @@ import pillar.api.file_storage.moving
|
|||||||
|
|
||||||
@attr.s
|
@attr.s
|
||||||
class NodeMover(object):
|
class NodeMover(object):
|
||||||
db = attr.ib(validator=attr.validators.instance_of(flask_pymongo.wrappers.Database))
|
db = attr.ib(validator=attr.validators.instance_of(pymongo.database.Database))
|
||||||
skip_gcs = attr.ib(default=False, validator=attr.validators.instance_of(bool))
|
skip_gcs = attr.ib(default=False, validator=attr.validators.instance_of(bool))
|
||||||
_log = attrs_extra.log('%s.NodeMover' % __name__)
|
_log = attrs_extra.log('%s.NodeMover' % __name__)
|
||||||
|
|
||||||
|
@@ -71,14 +71,19 @@ def before_delete_project(document):
|
|||||||
|
|
||||||
def after_delete_project(project: dict):
|
def after_delete_project(project: dict):
|
||||||
"""Perform delete on the project's files too."""
|
"""Perform delete on the project's files too."""
|
||||||
|
from werkzeug.exceptions import NotFound
|
||||||
from eve.methods.delete import delete
|
from eve.methods.delete import delete
|
||||||
|
|
||||||
pid = project['_id']
|
pid = project['_id']
|
||||||
log.info('Project %s was deleted, also deleting its files.', pid)
|
log.info('Project %s was deleted, also deleting its files.', pid)
|
||||||
|
|
||||||
|
try:
|
||||||
r, _, _, status = delete('files', {'project': pid})
|
r, _, _, status = delete('files', {'project': pid})
|
||||||
|
except NotFound:
|
||||||
|
# There were no files, and that's fine.
|
||||||
|
return
|
||||||
if status != 204:
|
if status != 204:
|
||||||
|
# Will never happen because bloody Eve always returns 204 or raises an exception.
|
||||||
log.warning('Unable to delete files of project %s: %s', pid, r)
|
log.warning('Unable to delete files of project %s: %s', pid, r)
|
||||||
|
|
||||||
|
|
||||||
|
@@ -142,7 +142,7 @@ def after_fetching_user(user):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Remove all fields except public ones.
|
# Remove all fields except public ones.
|
||||||
public_fields = {'full_name', 'username', 'email', 'extension_props_public'}
|
public_fields = {'full_name', 'username', 'email', 'extension_props_public', 'badges'}
|
||||||
for field in list(user.keys()):
|
for field in list(user.keys()):
|
||||||
if field not in public_fields:
|
if field not in public_fields:
|
||||||
del user[field]
|
del user[field]
|
||||||
|
@@ -1,9 +1,11 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from eve.methods.get import get
|
from eve.methods.get import get
|
||||||
from flask import Blueprint
|
from flask import Blueprint, request
|
||||||
|
import werkzeug.exceptions as wz_exceptions
|
||||||
|
|
||||||
from pillar.api.utils import jsonify
|
from pillar import current_app
|
||||||
|
from pillar.api import utils
|
||||||
from pillar.api.utils.authorization import require_login
|
from pillar.api.utils.authorization import require_login
|
||||||
from pillar.auth import current_user
|
from pillar.auth import current_user
|
||||||
|
|
||||||
@@ -15,7 +17,128 @@ blueprint_api = Blueprint('users_api', __name__)
|
|||||||
@require_login()
|
@require_login()
|
||||||
def my_info():
|
def my_info():
|
||||||
eve_resp, _, _, status, _ = get('users', {'_id': current_user.user_id})
|
eve_resp, _, _, status, _ = get('users', {'_id': current_user.user_id})
|
||||||
resp = jsonify(eve_resp['_items'][0], status=status)
|
resp = utils.jsonify(eve_resp['_items'][0], status=status)
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint_api.route('/video/<video_id>/progress')
|
||||||
|
@require_login()
|
||||||
|
def get_video_progress(video_id: str):
|
||||||
|
"""Return video progress information.
|
||||||
|
|
||||||
|
Either a `204 No Content` is returned (no information stored),
|
||||||
|
or a `200 Ok` with JSON from Eve's 'users' schema, from the key
|
||||||
|
video.view_progress.<video_id>.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Validation of the video ID; raises a BadRequest when it's not an ObjectID.
|
||||||
|
# This isn't strictly necessary, but it makes this function behave symmetrical
|
||||||
|
# to the set_video_progress() function.
|
||||||
|
utils.str2id(video_id)
|
||||||
|
|
||||||
|
users_coll = current_app.db('users')
|
||||||
|
user_doc = users_coll.find_one(current_user.user_id, projection={'nodes.view_progress': True})
|
||||||
|
try:
|
||||||
|
progress = user_doc['nodes']['view_progress'][video_id]
|
||||||
|
except KeyError:
|
||||||
|
return '', 204
|
||||||
|
if not progress:
|
||||||
|
return '', 204
|
||||||
|
|
||||||
|
return utils.jsonify(progress)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint_api.route('/video/<video_id>/progress', methods=['POST'])
|
||||||
|
@require_login()
|
||||||
|
def set_video_progress(video_id: str):
|
||||||
|
"""Save progress information about a certain video.
|
||||||
|
|
||||||
|
Expected parameters:
|
||||||
|
- progress_in_sec: float number of seconds
|
||||||
|
- progress_in_perc: integer percentage of video watched (interval [0-100])
|
||||||
|
"""
|
||||||
|
my_log = log.getChild('set_video_progress')
|
||||||
|
my_log.debug('Setting video progress for user %r video %r', current_user.user_id, video_id)
|
||||||
|
|
||||||
|
# Constructing this response requires an active app, and thus can't be done on module load.
|
||||||
|
no_video_response = utils.jsonify({'_message': 'No such video'}, status=404)
|
||||||
|
|
||||||
|
try:
|
||||||
|
progress_in_sec = float(request.form['progress_in_sec'])
|
||||||
|
progress_in_perc = int(request.form['progress_in_perc'])
|
||||||
|
except KeyError as ex:
|
||||||
|
my_log.debug('Missing POST field in request: %s', ex)
|
||||||
|
raise wz_exceptions.BadRequest(f'missing a form field')
|
||||||
|
except ValueError as ex:
|
||||||
|
my_log.debug('Invalid value for POST field in request: %s', ex)
|
||||||
|
raise wz_exceptions.BadRequest(f'Invalid value for field: {ex}')
|
||||||
|
|
||||||
|
users_coll = current_app.db('users')
|
||||||
|
nodes_coll = current_app.db('nodes')
|
||||||
|
|
||||||
|
# First check whether this is actually an existing video
|
||||||
|
video_oid = utils.str2id(video_id)
|
||||||
|
video_doc = nodes_coll.find_one(video_oid, projection={
|
||||||
|
'node_type': True,
|
||||||
|
'properties.content_type': True,
|
||||||
|
'properties.file': True,
|
||||||
|
})
|
||||||
|
if not video_doc:
|
||||||
|
my_log.debug('Node %r not found, unable to set progress for user %r',
|
||||||
|
video_oid, current_user.user_id)
|
||||||
|
return no_video_response
|
||||||
|
|
||||||
|
try:
|
||||||
|
is_video = (video_doc['node_type'] == 'asset'
|
||||||
|
and video_doc['properties']['content_type'] == 'video')
|
||||||
|
except KeyError:
|
||||||
|
is_video = False
|
||||||
|
|
||||||
|
if not is_video:
|
||||||
|
my_log.info('Node %r is not a video, unable to set progress for user %r',
|
||||||
|
video_oid, current_user.user_id)
|
||||||
|
# There is no video found at this URL, so act as if it doesn't even exist.
|
||||||
|
return no_video_response
|
||||||
|
|
||||||
|
# Compute the progress
|
||||||
|
percent = min(100, max(0, progress_in_perc))
|
||||||
|
progress = {
|
||||||
|
'progress_in_sec': progress_in_sec,
|
||||||
|
'progress_in_percent': percent,
|
||||||
|
'last_watched': utils.utcnow(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# After watching a certain percentage of the video, we consider it 'done'
|
||||||
|
#
|
||||||
|
# Total Credit start Total Credit Percent
|
||||||
|
# HH:MM:SS HH:MM:SS sec sec of duration
|
||||||
|
# Sintel 00:14:48 00:12:24 888 744 83.78%
|
||||||
|
# Tears of Steel 00:12:14 00:09:49 734 589 80.25%
|
||||||
|
# Cosmos Laundro 00:12:10 00:10:05 730 605 82.88%
|
||||||
|
# Agent 327 00:03:51 00:03:26 231 206 89.18%
|
||||||
|
# Caminandes 3 00:02:30 00:02:18 150 138 92.00%
|
||||||
|
# Glass Half 00:03:13 00:02:52 193 172 89.12%
|
||||||
|
# Big Buck Bunny 00:09:56 00:08:11 596 491 82.38%
|
||||||
|
# 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:
|
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 typing
|
||||||
|
|
||||||
import bson
|
import bson
|
||||||
from flask import g, current_app
|
from flask import g, current_app, session
|
||||||
from flask import request
|
from flask import request
|
||||||
from werkzeug import exceptions as wz_exceptions
|
from werkzeug import exceptions as wz_exceptions
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ def find_user_in_db(user_info: dict, provider='blender-id') -> dict:
|
|||||||
return db_user
|
return db_user
|
||||||
|
|
||||||
|
|
||||||
def validate_token(*, force=False):
|
def validate_token(*, force=False) -> bool:
|
||||||
"""Validate the token provided in the request and populate the current_user
|
"""Validate the token provided in the request and populate the current_user
|
||||||
flask.g object, so that permissions and access to a resource can be defined
|
flask.g object, so that permissions and access to a resource can be defined
|
||||||
from it.
|
from it.
|
||||||
@@ -115,7 +115,7 @@ def validate_token(*, force=False):
|
|||||||
:returns: True iff the user is logged in with a valid Blender ID token.
|
:returns: True iff the user is logged in with a valid Blender ID token.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pillar.auth import AnonymousUser
|
import pillar.auth
|
||||||
|
|
||||||
# Trust a pre-existing g.current_user
|
# Trust a pre-existing g.current_user
|
||||||
if not force:
|
if not force:
|
||||||
@@ -133,16 +133,22 @@ def validate_token(*, force=False):
|
|||||||
oauth_subclient = ''
|
oauth_subclient = ''
|
||||||
else:
|
else:
|
||||||
# Check the session, the user might be logged in through Flask-Login.
|
# Check the session, the user might be logged in through Flask-Login.
|
||||||
from pillar import auth
|
|
||||||
|
|
||||||
token = auth.get_blender_id_oauth_token()
|
# The user has a logged-in session; trust only if this request passes a CSRF check.
|
||||||
|
# FIXME(Sybren): we should stop saving the token as 'user_id' in the sesion.
|
||||||
|
token = session.get('user_id')
|
||||||
|
if token:
|
||||||
|
log.debug('skipping token check because current user already has a session')
|
||||||
|
current_app.csrf.protect()
|
||||||
|
else:
|
||||||
|
token = pillar.auth.get_blender_id_oauth_token()
|
||||||
oauth_subclient = None
|
oauth_subclient = None
|
||||||
|
|
||||||
if not token:
|
if not token:
|
||||||
# If no authorization headers are provided, we are getting a request
|
# If no authorization headers are provided, we are getting a request
|
||||||
# from a non logged in user. Proceed accordingly.
|
# from a non logged in user. Proceed accordingly.
|
||||||
log.debug('No authentication headers, so not logged in.')
|
log.debug('No authentication headers, so not logged in.')
|
||||||
g.current_user = AnonymousUser()
|
g.current_user = pillar.auth.AnonymousUser()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return validate_this_token(token, oauth_subclient) is not None
|
return validate_this_token(token, oauth_subclient) is not None
|
||||||
@@ -194,7 +200,7 @@ def remove_token(token: str):
|
|||||||
tokens_coll = current_app.db('tokens')
|
tokens_coll = current_app.db('tokens')
|
||||||
token_hashed = hash_auth_token(token)
|
token_hashed = hash_auth_token(token)
|
||||||
|
|
||||||
# TODO: remove matching on unhashed tokens once all tokens have been hashed.
|
# TODO: remove matching on hashed tokens once all hashed tokens have expired.
|
||||||
lookup = {'$or': [{'token': token}, {'token_hashed': token_hashed}]}
|
lookup = {'$or': [{'token': token}, {'token_hashed': token_hashed}]}
|
||||||
del_res = tokens_coll.delete_many(lookup)
|
del_res = tokens_coll.delete_many(lookup)
|
||||||
log.debug('Removed token %r, matched %d documents', token, del_res.deleted_count)
|
log.debug('Removed token %r, matched %d documents', token, del_res.deleted_count)
|
||||||
@@ -206,7 +212,7 @@ def find_token(token, is_subclient_token=False, **extra_filters):
|
|||||||
tokens_coll = current_app.db('tokens')
|
tokens_coll = current_app.db('tokens')
|
||||||
token_hashed = hash_auth_token(token)
|
token_hashed = hash_auth_token(token)
|
||||||
|
|
||||||
# TODO: remove matching on unhashed tokens once all tokens have been hashed.
|
# TODO: remove matching on hashed tokens once all hashed tokens have expired.
|
||||||
lookup = {'$or': [{'token': token}, {'token_hashed': token_hashed}],
|
lookup = {'$or': [{'token': token}, {'token_hashed': token_hashed}],
|
||||||
'is_subclient_token': True if is_subclient_token else {'$in': [False, None]},
|
'is_subclient_token': True if is_subclient_token else {'$in': [False, None]},
|
||||||
'expire_time': {"$gt": utcnow()}}
|
'expire_time': {"$gt": utcnow()}}
|
||||||
@@ -229,8 +235,14 @@ def hash_auth_token(token: str) -> str:
|
|||||||
return base64.b64encode(digest).decode('ascii')
|
return base64.b64encode(digest).decode('ascii')
|
||||||
|
|
||||||
|
|
||||||
def store_token(user_id, token: str, token_expiry, oauth_subclient_id=False,
|
def store_token(user_id,
|
||||||
org_roles: typing.Set[str] = frozenset()):
|
token: str,
|
||||||
|
token_expiry,
|
||||||
|
oauth_subclient_id=False,
|
||||||
|
*,
|
||||||
|
org_roles: typing.Set[str] = frozenset(),
|
||||||
|
oauth_scopes: typing.Optional[typing.List[str]] = None,
|
||||||
|
):
|
||||||
"""Stores an authentication token.
|
"""Stores an authentication token.
|
||||||
|
|
||||||
:returns: the token document from MongoDB
|
:returns: the token document from MongoDB
|
||||||
@@ -240,13 +252,15 @@ def store_token(user_id, token: str, token_expiry, oauth_subclient_id=False,
|
|||||||
|
|
||||||
token_data = {
|
token_data = {
|
||||||
'user': user_id,
|
'user': user_id,
|
||||||
'token_hashed': hash_auth_token(token),
|
'token': token,
|
||||||
'expire_time': token_expiry,
|
'expire_time': token_expiry,
|
||||||
}
|
}
|
||||||
if oauth_subclient_id:
|
if oauth_subclient_id:
|
||||||
token_data['is_subclient_token'] = True
|
token_data['is_subclient_token'] = True
|
||||||
if org_roles:
|
if org_roles:
|
||||||
token_data['org_roles'] = sorted(org_roles)
|
token_data['org_roles'] = sorted(org_roles)
|
||||||
|
if oauth_scopes:
|
||||||
|
token_data['oauth_scopes'] = oauth_scopes
|
||||||
|
|
||||||
r, _, _, status = current_app.post_internal('tokens', token_data)
|
r, _, _, status = current_app.post_internal('tokens', token_data)
|
||||||
|
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import functools
|
import functools
|
||||||
|
import typing
|
||||||
|
|
||||||
from bson import ObjectId
|
from bson import ObjectId
|
||||||
from flask import g
|
from flask import g
|
||||||
@@ -12,8 +13,9 @@ CHECK_PERMISSIONS_IMPLEMENTED_FOR = {'projects', 'nodes', 'flamenco_jobs'}
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def check_permissions(collection_name, resource, method, append_allowed_methods=False,
|
def check_permissions(collection_name: str, resource: dict, method: str,
|
||||||
check_node_type=None):
|
append_allowed_methods=False,
|
||||||
|
check_node_type: typing.Optional[str] = None):
|
||||||
"""Check user permissions to access a node. We look up node permissions from
|
"""Check user permissions to access a node. We look up node permissions from
|
||||||
world to groups to users and match them with the computed user permissions.
|
world to groups to users and match them with the computed user permissions.
|
||||||
If there is not match, we raise 403.
|
If there is not match, we raise 403.
|
||||||
@@ -93,8 +95,9 @@ def compute_allowed_methods(collection_name, resource, check_node_type=None):
|
|||||||
return allowed_methods
|
return allowed_methods
|
||||||
|
|
||||||
|
|
||||||
def has_permissions(collection_name, resource, method, append_allowed_methods=False,
|
def has_permissions(collection_name: str, resource: dict, method: str,
|
||||||
check_node_type=None):
|
append_allowed_methods=False,
|
||||||
|
check_node_type: typing.Optional[str] = None):
|
||||||
"""Check user permissions to access a node. We look up node permissions from
|
"""Check user permissions to access a node. We look up node permissions from
|
||||||
world to groups to users and match them with the computed user permissions.
|
world to groups to users and match them with the computed user permissions.
|
||||||
|
|
||||||
|
@@ -38,6 +38,8 @@ class UserClass(flask_login.UserMixin):
|
|||||||
self.groups: typing.List[str] = [] # NOTE: these are stringified object IDs.
|
self.groups: typing.List[str] = [] # NOTE: these are stringified object IDs.
|
||||||
self.group_ids: typing.List[bson.ObjectId] = []
|
self.group_ids: typing.List[bson.ObjectId] = []
|
||||||
self.capabilities: typing.Set[str] = set()
|
self.capabilities: typing.Set[str] = set()
|
||||||
|
self.nodes: dict = {} # see the 'nodes' key in eve_settings.py::user_schema.
|
||||||
|
self.badges_html: str = ''
|
||||||
|
|
||||||
# Lazily evaluated
|
# Lazily evaluated
|
||||||
self._has_organizations: typing.Optional[bool] = None
|
self._has_organizations: typing.Optional[bool] = None
|
||||||
@@ -56,6 +58,12 @@ class UserClass(flask_login.UserMixin):
|
|||||||
user.email = db_user.get('email') or ''
|
user.email = db_user.get('email') or ''
|
||||||
user.username = db_user.get('username') or ''
|
user.username = db_user.get('username') or ''
|
||||||
user.full_name = db_user.get('full_name') or ''
|
user.full_name = db_user.get('full_name') or ''
|
||||||
|
user.badges_html = db_user.get('badges', {}).get('html') or ''
|
||||||
|
|
||||||
|
# Be a little more specific than just db_user['nodes']
|
||||||
|
user.nodes = {
|
||||||
|
'view_progress': db_user.get('nodes', {}).get('view_progress', {}),
|
||||||
|
}
|
||||||
|
|
||||||
# Derived properties
|
# Derived properties
|
||||||
user.objectid = str(user.user_id or '')
|
user.objectid = str(user.user_id or '')
|
||||||
@@ -210,6 +218,11 @@ def login_user(oauth_token: str, *, load_from_db=False):
|
|||||||
user = _load_user(oauth_token)
|
user = _load_user(oauth_token)
|
||||||
else:
|
else:
|
||||||
user = UserClass(oauth_token)
|
user = UserClass(oauth_token)
|
||||||
|
login_user_object(user)
|
||||||
|
|
||||||
|
|
||||||
|
def login_user_object(user: UserClass):
|
||||||
|
"""Log in the given user."""
|
||||||
flask_login.login_user(user, remember=True)
|
flask_login.login_user(user, remember=True)
|
||||||
g.current_user = user
|
g.current_user = user
|
||||||
user_authenticated.send(None)
|
user_authenticated.send(None)
|
||||||
|
@@ -1,8 +1,9 @@
|
|||||||
import abc
|
import abc
|
||||||
import attr
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import typing
|
||||||
|
|
||||||
|
import attr
|
||||||
from rauth import OAuth2Service
|
from rauth import OAuth2Service
|
||||||
from flask import current_app, url_for, request, redirect, session, Response
|
from flask import current_app, url_for, request, redirect, session, Response
|
||||||
|
|
||||||
@@ -15,6 +16,8 @@ class OAuthUserResponse:
|
|||||||
|
|
||||||
id = attr.ib(validator=attr.validators.instance_of(str))
|
id = attr.ib(validator=attr.validators.instance_of(str))
|
||||||
email = attr.ib(validator=attr.validators.instance_of(str))
|
email = attr.ib(validator=attr.validators.instance_of(str))
|
||||||
|
access_token = attr.ib(validator=attr.validators.instance_of(str))
|
||||||
|
scopes: typing.List[str] = attr.ib(validator=attr.validators.instance_of(list))
|
||||||
|
|
||||||
|
|
||||||
class OAuthError(Exception):
|
class OAuthError(Exception):
|
||||||
@@ -127,8 +130,10 @@ class OAuthSignIn(metaclass=abc.ABCMeta):
|
|||||||
|
|
||||||
class BlenderIdSignIn(OAuthSignIn):
|
class BlenderIdSignIn(OAuthSignIn):
|
||||||
provider_name = 'blender-id'
|
provider_name = 'blender-id'
|
||||||
|
scopes = ['email', 'badge']
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
from urllib.parse import urljoin
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
base_url = current_app.config['BLENDER_ID_ENDPOINT']
|
base_url = current_app.config['BLENDER_ID_ENDPOINT']
|
||||||
@@ -137,14 +142,14 @@ class BlenderIdSignIn(OAuthSignIn):
|
|||||||
name='blender-id',
|
name='blender-id',
|
||||||
client_id=self.consumer_id,
|
client_id=self.consumer_id,
|
||||||
client_secret=self.consumer_secret,
|
client_secret=self.consumer_secret,
|
||||||
authorize_url='%s/oauth/authorize' % base_url,
|
authorize_url=urljoin(base_url, 'oauth/authorize'),
|
||||||
access_token_url='%s/oauth/token' % base_url,
|
access_token_url=urljoin(base_url, 'oauth/token'),
|
||||||
base_url='%s/api/' % base_url
|
base_url=urljoin(base_url, 'api/'),
|
||||||
)
|
)
|
||||||
|
|
||||||
def authorize(self):
|
def authorize(self):
|
||||||
return redirect(self.service.get_authorize_url(
|
return redirect(self.service.get_authorize_url(
|
||||||
scope='email',
|
scope=' '.join(self.scopes),
|
||||||
response_type='code',
|
response_type='code',
|
||||||
redirect_uri=self.get_callback_url())
|
redirect_uri=self.get_callback_url())
|
||||||
)
|
)
|
||||||
@@ -158,7 +163,11 @@ class BlenderIdSignIn(OAuthSignIn):
|
|||||||
|
|
||||||
session['blender_id_oauth_token'] = access_token
|
session['blender_id_oauth_token'] = access_token
|
||||||
me = oauth_session.get('user').json()
|
me = oauth_session.get('user').json()
|
||||||
return OAuthUserResponse(str(me['id']), me['email'])
|
|
||||||
|
# Blender ID doesn't tell us which scopes were granted by the user, so
|
||||||
|
# for now assume we got all the scopes we requested.
|
||||||
|
# (see https://github.com/jazzband/django-oauth-toolkit/issues/644)
|
||||||
|
return OAuthUserResponse(str(me['id']), me['email'], access_token, self.scopes)
|
||||||
|
|
||||||
|
|
||||||
class FacebookSignIn(OAuthSignIn):
|
class FacebookSignIn(OAuthSignIn):
|
||||||
@@ -188,7 +197,7 @@ class FacebookSignIn(OAuthSignIn):
|
|||||||
me = oauth_session.get('me?fields=id,email').json()
|
me = oauth_session.get('me?fields=id,email').json()
|
||||||
# TODO handle case when user chooses not to disclose en email
|
# TODO handle case when user chooses not to disclose en email
|
||||||
# see https://developers.facebook.com/docs/graph-api/reference/user/
|
# see https://developers.facebook.com/docs/graph-api/reference/user/
|
||||||
return OAuthUserResponse(me['id'], me.get('email'))
|
return OAuthUserResponse(me['id'], me.get('email'), '', [])
|
||||||
|
|
||||||
|
|
||||||
class GoogleSignIn(OAuthSignIn):
|
class GoogleSignIn(OAuthSignIn):
|
||||||
@@ -216,4 +225,4 @@ class GoogleSignIn(OAuthSignIn):
|
|||||||
oauth_session = self.make_oauth_session()
|
oauth_session = self.make_oauth_session()
|
||||||
|
|
||||||
me = oauth_session.get('userinfo').json()
|
me = oauth_session.get('userinfo').json()
|
||||||
return OAuthUserResponse(str(me['id']), me['email'])
|
return OAuthUserResponse(str(me['id']), me['email'], '', [])
|
||||||
|
183
pillar/badge_sync.py
Normal file
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.operations import manager_operations
|
||||||
from pillar.cli.setup import manager_setup
|
from pillar.cli.setup import manager_setup
|
||||||
from pillar.cli.elastic import manager_elastic
|
from pillar.cli.elastic import manager_elastic
|
||||||
|
from . import badges
|
||||||
|
|
||||||
from pillar.cli import translations
|
from pillar.cli import translations
|
||||||
|
|
||||||
@@ -24,3 +25,4 @@ manager.add_command("maintenance", manager_maintenance)
|
|||||||
manager.add_command("setup", manager_setup)
|
manager.add_command("setup", manager_setup)
|
||||||
manager.add_command("operations", manager_operations)
|
manager.add_command("operations", manager_operations)
|
||||||
manager.add_command("elastic", manager_elastic)
|
manager.add_command("elastic", manager_elastic)
|
||||||
|
manager.add_command("badges", badges.manager)
|
||||||
|
39
pillar/cli/badges.py
Normal file
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)
|
@@ -684,7 +684,7 @@ def upgrade_attachment_schema(proj_url=None, all_projects=False, go=False):
|
|||||||
log_proj()
|
log_proj()
|
||||||
log.info('Removed %d empty attachment dicts', res.modified_count)
|
log.info('Removed %d empty attachment dicts', res.modified_count)
|
||||||
else:
|
else:
|
||||||
to_remove = nodes_coll.count({'properties.attachments': {},
|
to_remove = nodes_coll.count_documents({'properties.attachments': {},
|
||||||
'project': project['_id']})
|
'project': project['_id']})
|
||||||
if to_remove:
|
if to_remove:
|
||||||
log_proj()
|
log_proj()
|
||||||
@@ -767,7 +767,9 @@ def iter_markdown(proj_node_types: dict, some_node: dict, callback: typing.Calla
|
|||||||
continue
|
continue
|
||||||
to_visit.append((subdoc, definition['schema']))
|
to_visit.append((subdoc, definition['schema']))
|
||||||
continue
|
continue
|
||||||
if definition.get('coerce') != 'markdown':
|
coerce = definition.get('coerce') # Eve < 0.8
|
||||||
|
validator = definition.get('validator') # Eve >= 0.8
|
||||||
|
if coerce != 'markdown' and validator != 'markdown':
|
||||||
continue
|
continue
|
||||||
|
|
||||||
my_log.debug('I have to change %r of %s', key, doc)
|
my_log.debug('I have to change %r of %s', key, doc)
|
||||||
|
@@ -1,6 +1,8 @@
|
|||||||
|
from collections import defaultdict
|
||||||
|
import datetime
|
||||||
import os.path
|
import os.path
|
||||||
from os import getenv
|
from os import getenv
|
||||||
from collections import defaultdict
|
|
||||||
import requests.certs
|
import requests.certs
|
||||||
|
|
||||||
# Certificate file for communication with other systems.
|
# Certificate file for communication with other systems.
|
||||||
@@ -29,10 +31,11 @@ DEBUG = False
|
|||||||
SECRET_KEY = ''
|
SECRET_KEY = ''
|
||||||
|
|
||||||
# Authentication token hashing key. If empty falls back to UTF8-encoded SECRET_KEY with a warning.
|
# Authentication token hashing key. If empty falls back to UTF8-encoded SECRET_KEY with a warning.
|
||||||
|
# Not used to hash new tokens, but it is used to check pre-existing hashed tokens.
|
||||||
AUTH_TOKEN_HMAC_KEY = b''
|
AUTH_TOKEN_HMAC_KEY = b''
|
||||||
|
|
||||||
# Authentication settings
|
# Authentication settings
|
||||||
BLENDER_ID_ENDPOINT = 'http://id.local:8000'
|
BLENDER_ID_ENDPOINT = 'http://id.local:8000/'
|
||||||
|
|
||||||
CDN_USE_URL_SIGNING = True
|
CDN_USE_URL_SIGNING = True
|
||||||
CDN_SERVICE_DOMAIN_PROTOCOL = 'https'
|
CDN_SERVICE_DOMAIN_PROTOCOL = 'https'
|
||||||
@@ -203,7 +206,17 @@ CELERY_BEAT_SCHEDULE = {
|
|||||||
'schedule': 600, # every N seconds
|
'schedule': 600, # every N seconds
|
||||||
'args': ('gcs', 100)
|
'args': ('gcs', 100)
|
||||||
},
|
},
|
||||||
|
'refresh-blenderid-badges': {
|
||||||
|
'task': 'pillar.celery.badges.sync_badges_for_users',
|
||||||
|
'schedule': 600, # every N seconds
|
||||||
|
'args': (540, ), # time limit in seconds, keep shorter than 'schedule'
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Badges will be re-fetched every timedelta.
|
||||||
|
# TODO(Sybren): A proper value should be determined after we actually have users with badges.
|
||||||
|
BLENDER_ID_BADGE_EXPIRY = datetime.timedelta(hours=4)
|
||||||
|
|
||||||
|
|
||||||
# Mapping from user role to capabilities obtained by users with that role.
|
# Mapping from user role to capabilities obtained by users with that role.
|
||||||
USER_CAPABILITIES = defaultdict(**{
|
USER_CAPABILITIES = defaultdict(**{
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
|
import flask
|
||||||
|
import raven.breadcrumbs
|
||||||
from raven.contrib.flask import Sentry
|
from raven.contrib.flask import Sentry
|
||||||
|
|
||||||
from .auth import current_user
|
from .auth import current_user
|
||||||
@@ -14,16 +16,14 @@ class PillarSentry(Sentry):
|
|||||||
def init_app(self, app, *args, **kwargs):
|
def init_app(self, app, *args, **kwargs):
|
||||||
super().init_app(app, *args, **kwargs)
|
super().init_app(app, *args, **kwargs)
|
||||||
|
|
||||||
# We perform authentication of the user while handling the request,
|
flask.request_started.connect(self.__add_sentry_breadcrumbs, self)
|
||||||
# so Sentry calls get_user_info() too early.
|
|
||||||
|
|
||||||
def get_user_context_again(self, ):
|
def __add_sentry_breadcrumbs(self, sender, **extra):
|
||||||
from flask import request
|
raven.breadcrumbs.record(
|
||||||
|
message='Request started',
|
||||||
try:
|
category='http',
|
||||||
self.client.user_context(self.get_user_info(request))
|
data={'url': flask.request.url}
|
||||||
except Exception as e:
|
)
|
||||||
self.client.logger.exception(str(e))
|
|
||||||
|
|
||||||
def get_user_info(self, request):
|
def get_user_info(self, request):
|
||||||
user_info = super().get_user_info(request)
|
user_info = super().get_user_info(request)
|
||||||
|
@@ -163,8 +163,11 @@ class YouTube:
|
|||||||
return html_module.escape('{youtube invalid YouTube ID/URL}')
|
return html_module.escape('{youtube invalid YouTube ID/URL}')
|
||||||
|
|
||||||
src = f'https://www.youtube.com/embed/{youtube_id}?rel=0'
|
src = f'https://www.youtube.com/embed/{youtube_id}?rel=0'
|
||||||
html = f'<iframe class="shortcode youtube" width="{width}" height="{height}" src="{src}"' \
|
html = f'<div class="embed-responsive embed-responsive-16by9">' \
|
||||||
f' frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>'
|
f'<iframe class="shortcode youtube embed-responsive-item"' \
|
||||||
|
f' width="{width}" height="{height}" src="{src}"' \
|
||||||
|
f' frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>' \
|
||||||
|
f'</div>'
|
||||||
return html
|
return html
|
||||||
|
|
||||||
|
|
||||||
@@ -225,12 +228,25 @@ class Attachment:
|
|||||||
|
|
||||||
return self.render(file_doc, pargs, kwargs)
|
return self.render(file_doc, pargs, kwargs)
|
||||||
|
|
||||||
def sdk_file(self, slug: str, node_properties: dict) -> pillarsdk.File:
|
def sdk_file(self, slug: str, document: dict) -> pillarsdk.File:
|
||||||
"""Return the file document for the attachment with this slug."""
|
"""Return the file document for the attachment with this slug."""
|
||||||
|
|
||||||
from pillar.web import system_util
|
from pillar.web import system_util
|
||||||
|
|
||||||
attachments = node_properties.get('attachments', {})
|
# TODO (fsiddi) Make explicit what 'document' is.
|
||||||
|
# In some cases we pass the entire node or project documents, in other cases
|
||||||
|
# we pass node.properties. This should be unified at the level of do_markdown.
|
||||||
|
# For now we do a quick hack and first look for 'properties' in the doc,
|
||||||
|
# then we look for 'attachments'.
|
||||||
|
|
||||||
|
doc_properties = document.get('properties')
|
||||||
|
if doc_properties:
|
||||||
|
# We passed an entire document (all nodes must have 'properties')
|
||||||
|
attachments = doc_properties.get('attachments', {})
|
||||||
|
else:
|
||||||
|
# The value of document could have been defined as 'node.properties'
|
||||||
|
attachments = document.get('attachments', {})
|
||||||
|
|
||||||
attachment = attachments.get(slug)
|
attachment = attachments.get(slug)
|
||||||
if not attachment:
|
if not attachment:
|
||||||
raise self.NoSuchSlug(slug)
|
raise self.NoSuchSlug(slug)
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
# -*- encoding: utf-8 -*-
|
# -*- encoding: utf-8 -*-
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import contextlib
|
||||||
import copy
|
import copy
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
@@ -10,11 +11,7 @@ import pathlib
|
|||||||
import sys
|
import sys
|
||||||
import typing
|
import typing
|
||||||
import unittest.mock
|
import unittest.mock
|
||||||
|
from urllib.parse import urlencode, urljoin
|
||||||
try:
|
|
||||||
from urllib.parse import urlencode
|
|
||||||
except ImportError:
|
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
from bson import ObjectId, tz_util
|
from bson import ObjectId, tz_util
|
||||||
|
|
||||||
@@ -27,6 +24,7 @@ from eve.tests import TestMinimal
|
|||||||
import pymongo.collection
|
import pymongo.collection
|
||||||
from flask.testing import FlaskClient
|
from flask.testing import FlaskClient
|
||||||
import flask.ctx
|
import flask.ctx
|
||||||
|
import flask.wrappers
|
||||||
import responses
|
import responses
|
||||||
|
|
||||||
import pillar
|
import pillar
|
||||||
@@ -185,7 +183,7 @@ class AbstractPillarTest(TestMinimal):
|
|||||||
else:
|
else:
|
||||||
self.ensure_project_exists()
|
self.ensure_project_exists()
|
||||||
|
|
||||||
with self.app.test_request_context():
|
with self.app.app_context():
|
||||||
files_collection = self.app.data.driver.db['files']
|
files_collection = self.app.data.driver.db['files']
|
||||||
assert isinstance(files_collection, pymongo.collection.Collection)
|
assert isinstance(files_collection, pymongo.collection.Collection)
|
||||||
|
|
||||||
@@ -326,15 +324,46 @@ class AbstractPillarTest(TestMinimal):
|
|||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def create_valid_auth_token(self, user_id, token='token'):
|
@contextlib.contextmanager
|
||||||
|
def login_as(self, user_id: typing.Union[str, ObjectId]):
|
||||||
|
"""Context manager, within the context the app context is active and the user logged in.
|
||||||
|
|
||||||
|
The logging-in happens when a request starts, so it's only active when
|
||||||
|
e.g. self.get() or self.post() or somesuch request is used.
|
||||||
|
"""
|
||||||
|
from pillar.auth import UserClass, login_user_object
|
||||||
|
|
||||||
|
if isinstance(user_id, str):
|
||||||
|
user_oid = ObjectId(user_id)
|
||||||
|
elif isinstance(user_id, ObjectId):
|
||||||
|
user_oid = user_id
|
||||||
|
else:
|
||||||
|
raise TypeError(f'invalid type {type(user_id)} for parameter user_id')
|
||||||
|
user_doc = self.fetch_user_from_db(user_oid)
|
||||||
|
|
||||||
|
def signal_handler(sender, **kwargs):
|
||||||
|
login_user_object(user)
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
user = UserClass.construct('', user_doc)
|
||||||
|
with flask.request_started.connected_to(signal_handler, self.app):
|
||||||
|
yield
|
||||||
|
|
||||||
|
# TODO: rename to 'create_auth_token' now that 'expire_in_days' can be negative.
|
||||||
|
def create_valid_auth_token(self,
|
||||||
|
user_id: ObjectId,
|
||||||
|
token='token',
|
||||||
|
*,
|
||||||
|
oauth_scopes: typing.Optional[typing.List[str]]=None,
|
||||||
|
expire_in_days=1) -> dict:
|
||||||
from pillar.api.utils import utcnow
|
from pillar.api.utils import utcnow
|
||||||
|
|
||||||
future = utcnow() + datetime.timedelta(days=1)
|
future = utcnow() + datetime.timedelta(days=expire_in_days)
|
||||||
|
|
||||||
with self.app.test_request_context():
|
with self.app.test_request_context():
|
||||||
from pillar.api.utils import authentication as auth
|
from pillar.api.utils import authentication as auth
|
||||||
|
|
||||||
token_data = auth.store_token(user_id, token, future, None)
|
token_data = auth.store_token(user_id, token, future, oauth_scopes=oauth_scopes)
|
||||||
|
|
||||||
return token_data
|
return token_data
|
||||||
|
|
||||||
@@ -364,7 +393,7 @@ class AbstractPillarTest(TestMinimal):
|
|||||||
|
|
||||||
return user_id
|
return user_id
|
||||||
|
|
||||||
def create_node(self, node_doc):
|
def create_node(self, node_doc) -> ObjectId:
|
||||||
"""Creates a node, returning its ObjectId. """
|
"""Creates a node, returning its ObjectId. """
|
||||||
|
|
||||||
with self.app.test_request_context():
|
with self.app.test_request_context():
|
||||||
@@ -406,7 +435,7 @@ class AbstractPillarTest(TestMinimal):
|
|||||||
"""Sets up Responses to mock unhappy validation flow."""
|
"""Sets up Responses to mock unhappy validation flow."""
|
||||||
|
|
||||||
responses.add(responses.POST,
|
responses.add(responses.POST,
|
||||||
'%s/u/validate_token' % self.app.config['BLENDER_ID_ENDPOINT'],
|
urljoin(self.app.config['BLENDER_ID_ENDPOINT'], 'u/validate_token'),
|
||||||
json={'status': 'fail'},
|
json={'status': 'fail'},
|
||||||
status=403)
|
status=403)
|
||||||
|
|
||||||
@@ -414,7 +443,7 @@ class AbstractPillarTest(TestMinimal):
|
|||||||
"""Sets up Responses to mock happy validation flow."""
|
"""Sets up Responses to mock happy validation flow."""
|
||||||
|
|
||||||
responses.add(responses.POST,
|
responses.add(responses.POST,
|
||||||
'%s/u/validate_token' % self.app.config['BLENDER_ID_ENDPOINT'],
|
urljoin(self.app.config['BLENDER_ID_ENDPOINT'], 'u/validate_token'),
|
||||||
json=BLENDER_ID_USER_RESPONSE,
|
json=BLENDER_ID_USER_RESPONSE,
|
||||||
status=200)
|
status=200)
|
||||||
|
|
||||||
@@ -485,11 +514,10 @@ class AbstractPillarTest(TestMinimal):
|
|||||||
|
|
||||||
def client_request(self, method, path, qs=None, expected_status=200, auth_token=None, json=None,
|
def client_request(self, method, path, qs=None, expected_status=200, auth_token=None, json=None,
|
||||||
data=None, headers=None, files=None, content_type=None, etag=None,
|
data=None, headers=None, files=None, content_type=None, etag=None,
|
||||||
environ_overrides=None):
|
environ_overrides=None) -> flask.wrappers.Response:
|
||||||
"""Performs a HTTP request to the server."""
|
"""Performs a HTTP request to the server."""
|
||||||
|
|
||||||
from pillar.api.utils import dumps
|
from pillar.api.utils import dumps
|
||||||
import json as mod_json
|
|
||||||
|
|
||||||
headers = headers or {}
|
headers = headers or {}
|
||||||
environ_overrides = environ_overrides or {}
|
environ_overrides = environ_overrides or {}
|
||||||
@@ -522,29 +550,21 @@ class AbstractPillarTest(TestMinimal):
|
|||||||
expected_status, resp.status_code, resp.data
|
expected_status, resp.status_code, resp.data
|
||||||
))
|
))
|
||||||
|
|
||||||
def get_json():
|
|
||||||
if resp.mimetype != 'application/json':
|
|
||||||
raise TypeError('Unable to load JSON from mimetype %r' % resp.mimetype)
|
|
||||||
return mod_json.loads(resp.data)
|
|
||||||
|
|
||||||
resp.json = get_json
|
|
||||||
resp.get_json = get_json
|
|
||||||
|
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
def get(self, *args, **kwargs):
|
def get(self, *args, **kwargs) -> flask.wrappers.Response:
|
||||||
return self.client_request('GET', *args, **kwargs)
|
return self.client_request('GET', *args, **kwargs)
|
||||||
|
|
||||||
def post(self, *args, **kwargs):
|
def post(self, *args, **kwargs) -> flask.wrappers.Response:
|
||||||
return self.client_request('POST', *args, **kwargs)
|
return self.client_request('POST', *args, **kwargs)
|
||||||
|
|
||||||
def put(self, *args, **kwargs):
|
def put(self, *args, **kwargs) -> flask.wrappers.Response:
|
||||||
return self.client_request('PUT', *args, **kwargs)
|
return self.client_request('PUT', *args, **kwargs)
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs) -> flask.wrappers.Response:
|
||||||
return self.client_request('DELETE', *args, **kwargs)
|
return self.client_request('DELETE', *args, **kwargs)
|
||||||
|
|
||||||
def patch(self, *args, **kwargs):
|
def patch(self, *args, **kwargs) -> flask.wrappers.Response:
|
||||||
return self.client_request('PATCH', *args, **kwargs)
|
return self.client_request('PATCH', *args, **kwargs)
|
||||||
|
|
||||||
def assertAllowsAccess(self,
|
def assertAllowsAccess(self,
|
||||||
@@ -561,7 +581,7 @@ class AbstractPillarTest(TestMinimal):
|
|||||||
raise TypeError('expected_user_id should be a string or ObjectId, '
|
raise TypeError('expected_user_id should be a string or ObjectId, '
|
||||||
f'but is {expected_user_id!r}')
|
f'but is {expected_user_id!r}')
|
||||||
|
|
||||||
resp = self.get('/api/users/me', expected_status=200, auth_token=token).json()
|
resp = self.get('/api/users/me', expected_status=200, auth_token=token).get_json()
|
||||||
|
|
||||||
if expected_user_id:
|
if expected_user_id:
|
||||||
self.assertEqual(resp['_id'], str(expected_user_id))
|
self.assertEqual(resp['_id'], str(expected_user_id))
|
||||||
|
@@ -1,9 +1,9 @@
|
|||||||
"""Flask configuration file for unit testing."""
|
"""Flask configuration file for unit testing."""
|
||||||
|
|
||||||
BLENDER_ID_ENDPOINT = 'http://id.local:8001' # Non existant server
|
BLENDER_ID_ENDPOINT = 'http://id.local:8001/' # Non existant server
|
||||||
|
|
||||||
SERVER_NAME = 'localhost'
|
SERVER_NAME = 'localhost.local'
|
||||||
PILLAR_SERVER_ENDPOINT = 'http://localhost/api/'
|
PILLAR_SERVER_ENDPOINT = 'http://localhost.local/api/'
|
||||||
|
|
||||||
MAIN_PROJECT_ID = '5672beecc0261b2005ed1a33'
|
MAIN_PROJECT_ID = '5672beecc0261b2005ed1a33'
|
||||||
|
|
||||||
@@ -44,3 +44,5 @@ ELASTIC_INDICES = {
|
|||||||
|
|
||||||
# MUST be 8 characters long, see pillar.flask_extra.HashedPathConverter
|
# MUST be 8 characters long, see pillar.flask_extra.HashedPathConverter
|
||||||
STATIC_FILE_HASH = 'abcd1234'
|
STATIC_FILE_HASH = 'abcd1234'
|
||||||
|
|
||||||
|
CACHE_NO_NULL_WARNING = True
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
from pillar.api.eve_settings import *
|
from pillar.api.eve_settings import *
|
||||||
|
|
||||||
MONGO_DBNAME = 'pillar_test'
|
MONGO_DBNAME = 'pillar_test'
|
||||||
|
MONGO_USERNAME = None
|
||||||
|
|
||||||
|
|
||||||
def override_eve():
|
def override_eve():
|
||||||
@@ -10,5 +11,7 @@ def override_eve():
|
|||||||
test_settings.MONGO_HOST = MONGO_HOST
|
test_settings.MONGO_HOST = MONGO_HOST
|
||||||
test_settings.MONGO_PORT = MONGO_PORT
|
test_settings.MONGO_PORT = MONGO_PORT
|
||||||
test_settings.MONGO_DBNAME = MONGO_DBNAME
|
test_settings.MONGO_DBNAME = MONGO_DBNAME
|
||||||
|
test_settings.MONGO1_USERNAME = MONGO_USERNAME
|
||||||
tests.MONGO_HOST = MONGO_HOST
|
tests.MONGO_HOST = MONGO_HOST
|
||||||
tests.MONGO_DBNAME = MONGO_DBNAME
|
tests.MONGO_DBNAME = MONGO_DBNAME
|
||||||
|
tests.MONGO_USERNAME = MONGO_USERNAME
|
||||||
|
@@ -21,7 +21,7 @@ def attachment_form_group_create(schema_prop):
|
|||||||
def _attachment_build_single_field(schema_prop):
|
def _attachment_build_single_field(schema_prop):
|
||||||
# Ugly hard-coded schema.
|
# Ugly hard-coded schema.
|
||||||
fake_schema = {
|
fake_schema = {
|
||||||
'slug': schema_prop['propertyschema'],
|
'slug': schema_prop['keyschema'],
|
||||||
'oid': schema_prop['valueschema']['schema']['oid'],
|
'oid': schema_prop['valueschema']['schema']['oid'],
|
||||||
}
|
}
|
||||||
file_select_form_group = build_file_select_form(fake_schema)
|
file_select_form_group = build_file_select_form(fake_schema)
|
||||||
|
@@ -61,16 +61,10 @@ def posts_view(project_id=None, project_url=None, url=None, *, archive=False, pa
|
|||||||
post.picture = get_file(post.picture, api=api)
|
post.picture = get_file(post.picture, api=api)
|
||||||
post.url = url_for_node(node=post)
|
post.url = url_for_node(node=post)
|
||||||
|
|
||||||
# Use the *_main_project.html template for the main blog
|
|
||||||
is_main_project = project_id == current_app.config['MAIN_PROJECT_ID']
|
|
||||||
main_project_template = '_main_project' if is_main_project else ''
|
|
||||||
main_project_template = '_main_project'
|
|
||||||
index_arch = 'archive' if archive else 'index'
|
index_arch = 'archive' if archive else 'index'
|
||||||
template_path = f'nodes/custom/blog/{index_arch}{main_project_template}.html',
|
template_path = f'nodes/custom/blog/{index_arch}.html',
|
||||||
|
|
||||||
if url:
|
if url:
|
||||||
template_path = f'nodes/custom/post/view{main_project_template}.html',
|
|
||||||
|
|
||||||
post = Node.find_one({
|
post = Node.find_one({
|
||||||
'where': {'parent': blog._id, 'properties.url': url},
|
'where': {'parent': blog._id, 'properties.url': url},
|
||||||
'embedded': {'node_type': 1, 'user': 1},
|
'embedded': {'node_type': 1, 'user': 1},
|
||||||
@@ -95,6 +89,7 @@ def posts_view(project_id=None, project_url=None, url=None, *, archive=False, pa
|
|||||||
can_create_blog_posts = project.node_type_has_method('post', 'POST', api=api)
|
can_create_blog_posts = project.node_type_has_method('post', 'POST', api=api)
|
||||||
|
|
||||||
# Use functools.partial so we can later pass page=X.
|
# Use functools.partial so we can later pass page=X.
|
||||||
|
is_main_project = project_id == current_app.config['MAIN_PROJECT_ID']
|
||||||
if is_main_project:
|
if is_main_project:
|
||||||
url_func = functools.partial(url_for, 'main.main_blog_archive')
|
url_func = functools.partial(url_for, 'main.main_blog_archive')
|
||||||
else:
|
else:
|
||||||
@@ -121,7 +116,7 @@ def posts_view(project_id=None, project_url=None, url=None, *, archive=False, pa
|
|||||||
return render_template(
|
return render_template(
|
||||||
template_path,
|
template_path,
|
||||||
blog=blog,
|
blog=blog,
|
||||||
node=post,
|
node=post, # node is used by the generic comments rendering (see custom/_scripts.pug)
|
||||||
posts=posts._items,
|
posts=posts._items,
|
||||||
posts_meta=pmeta,
|
posts_meta=pmeta,
|
||||||
more_posts_available=pmeta['total'] > pmeta['max_results'],
|
more_posts_available=pmeta['total'] > pmeta['max_results'],
|
||||||
|
@@ -94,6 +94,16 @@ def find_for_post(project, node):
|
|||||||
url=node.properties.url)
|
url=node.properties.url)
|
||||||
|
|
||||||
|
|
||||||
|
@register_node_finder('page')
|
||||||
|
def find_for_page(project, node):
|
||||||
|
"""Returns the URL for a page."""
|
||||||
|
|
||||||
|
project_id = project['_id']
|
||||||
|
|
||||||
|
the_project = project_url(project_id, project=project)
|
||||||
|
return url_for('projects.view_node', project_url=the_project.url, node_id=node.properties.url)
|
||||||
|
|
||||||
|
|
||||||
def find_for_other(project, node):
|
def find_for_other(project, node):
|
||||||
"""Fallback: Assets, textures, and other node types.
|
"""Fallback: Assets, textures, and other node types.
|
||||||
|
|
||||||
|
@@ -1,9 +1,10 @@
|
|||||||
|
import functools
|
||||||
import logging
|
import logging
|
||||||
|
import typing
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from datetime import date
|
from datetime import date
|
||||||
import pillarsdk
|
import pillarsdk
|
||||||
from flask import current_app
|
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import StringField
|
from wtforms import StringField
|
||||||
from wtforms import DateField
|
from wtforms import DateField
|
||||||
@@ -17,6 +18,8 @@ from wtforms import DateTimeField
|
|||||||
from wtforms import SelectMultipleField
|
from wtforms import SelectMultipleField
|
||||||
from wtforms import FieldList
|
from wtforms import FieldList
|
||||||
from wtforms.validators import DataRequired
|
from wtforms.validators import DataRequired
|
||||||
|
|
||||||
|
from pillar import current_app
|
||||||
from pillar.web.utils import system_util
|
from pillar.web.utils import system_util
|
||||||
from pillar.web.utils.forms import FileSelectField
|
from pillar.web.utils.forms import FileSelectField
|
||||||
from pillar.web.utils.forms import CustomFormField
|
from pillar.web.utils.forms import CustomFormField
|
||||||
@@ -44,6 +47,13 @@ def iter_node_properties(node_type):
|
|||||||
yield prop_name, prop_schema, prop_fschema
|
yield prop_name, prop_schema, prop_fschema
|
||||||
|
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=1)
|
||||||
|
def tag_choices() -> typing.List[typing.Tuple[str, str]]:
|
||||||
|
"""Return (value, label) tuples for the NODE_TAGS config setting."""
|
||||||
|
tags = current_app.config.get('NODE_TAGS') or []
|
||||||
|
return [(tag, tag.title()) for tag in tags] # (value, label) tuples
|
||||||
|
|
||||||
|
|
||||||
def add_form_properties(form_class, node_type):
|
def add_form_properties(form_class, node_type):
|
||||||
"""Add fields to a form based on the node and form schema provided.
|
"""Add fields to a form based on the node and form schema provided.
|
||||||
:type node_schema: dict
|
:type node_schema: dict
|
||||||
@@ -60,7 +70,9 @@ def add_form_properties(form_class, node_type):
|
|||||||
# Recursive call if detects a dict
|
# Recursive call if detects a dict
|
||||||
field_type = schema_prop['type']
|
field_type = schema_prop['type']
|
||||||
|
|
||||||
if field_type == 'dict':
|
if prop_name == 'tags' and field_type == 'list':
|
||||||
|
field = SelectMultipleField(choices=tag_choices())
|
||||||
|
elif field_type == 'dict':
|
||||||
assert prop_name == 'attachments'
|
assert prop_name == 'attachments'
|
||||||
field = attachments.attachment_form_group_create(schema_prop)
|
field = attachments.attachment_form_group_create(schema_prop)
|
||||||
elif field_type == 'list':
|
elif field_type == 'list':
|
||||||
|
@@ -24,6 +24,7 @@ from pillar import current_app
|
|||||||
from pillar.api.utils import utcnow
|
from pillar.api.utils import utcnow
|
||||||
from pillar.web import system_util
|
from pillar.web import system_util
|
||||||
from pillar.web import utils
|
from pillar.web import utils
|
||||||
|
from pillar.web.nodes import finders
|
||||||
from pillar.web.utils.jstree import jstree_get_children
|
from pillar.web.utils.jstree import jstree_get_children
|
||||||
import pillar.extension
|
import pillar.extension
|
||||||
|
|
||||||
@@ -302,6 +303,52 @@ def view(project_url):
|
|||||||
'header_video_node': header_video_node})
|
'header_video_node': header_video_node})
|
||||||
|
|
||||||
|
|
||||||
|
def project_navigation_links(project, api) -> list:
|
||||||
|
"""Returns a list of nodes for the project, for top navigation display.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project: A Project object.
|
||||||
|
api: the api client credential.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of links for the Project.
|
||||||
|
For example we display a link to the project blog if present, as well
|
||||||
|
as pages. The list is structured as follows:
|
||||||
|
|
||||||
|
[{'url': '/p/spring/about', 'label': 'About'},
|
||||||
|
{'url': '/p/spring/blog', 'label': 'Blog'}]
|
||||||
|
"""
|
||||||
|
|
||||||
|
links = []
|
||||||
|
|
||||||
|
# Fetch the blog
|
||||||
|
blog = Node.find_first({
|
||||||
|
'where': {'project': project._id, 'node_type': 'blog', '_deleted': {'$ne': True}},
|
||||||
|
'projection': {
|
||||||
|
'name': 1,
|
||||||
|
}
|
||||||
|
}, api=api)
|
||||||
|
|
||||||
|
if blog:
|
||||||
|
links.append({'url': finders.find_url_for_node(blog), 'label': blog.name})
|
||||||
|
|
||||||
|
# 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})
|
||||||
|
|
||||||
|
return links
|
||||||
|
|
||||||
|
|
||||||
def render_project(project, api, extra_context=None, template_name=None):
|
def render_project(project, api, extra_context=None, template_name=None):
|
||||||
project.picture_square = utils.get_file(project.picture_square, api=api)
|
project.picture_square = utils.get_file(project.picture_square, api=api)
|
||||||
project.picture_header = utils.get_file(project.picture_header, api=api)
|
project.picture_header = utils.get_file(project.picture_header, api=api)
|
||||||
@@ -370,6 +417,8 @@ def render_project(project, api, extra_context=None, template_name=None):
|
|||||||
|
|
||||||
extension_sidebar_links = current_app.extension_sidebar_links(project)
|
extension_sidebar_links = current_app.extension_sidebar_links(project)
|
||||||
|
|
||||||
|
navigation_links = project_navigation_links(project, api)
|
||||||
|
|
||||||
return render_template(template_name,
|
return render_template(template_name,
|
||||||
api=api,
|
api=api,
|
||||||
project=project,
|
project=project,
|
||||||
@@ -378,6 +427,7 @@ def render_project(project, api, extra_context=None, template_name=None):
|
|||||||
show_project=True,
|
show_project=True,
|
||||||
og_picture=project.picture_header,
|
og_picture=project.picture_header,
|
||||||
activity_stream=activity_stream,
|
activity_stream=activity_stream,
|
||||||
|
navigation_links=navigation_links,
|
||||||
extension_sidebar_links=extension_sidebar_links,
|
extension_sidebar_links=extension_sidebar_links,
|
||||||
**extra_context)
|
**extra_context)
|
||||||
|
|
||||||
@@ -447,16 +497,14 @@ def view_node(project_url, node_id):
|
|||||||
|
|
||||||
# Append _theatre to load the proper template
|
# Append _theatre to load the proper template
|
||||||
theatre = '_theatre' if theatre_mode else ''
|
theatre = '_theatre' if theatre_mode else ''
|
||||||
|
navigation_links = project_navigation_links(project, api)
|
||||||
|
|
||||||
if node.node_type == 'page':
|
if node.node_type == 'page':
|
||||||
pages = Node.all({
|
|
||||||
'where': {'project': project._id, 'node_type': 'page'},
|
|
||||||
'projection': {'name': 1}}, api=api)
|
|
||||||
return render_template('nodes/custom/page/view_embed.html',
|
return render_template('nodes/custom/page/view_embed.html',
|
||||||
api=api,
|
api=api,
|
||||||
node=node,
|
node=node,
|
||||||
project=project,
|
project=project,
|
||||||
pages=pages._items,
|
navigation_links=navigation_links,
|
||||||
og_picture=og_picture,)
|
og_picture=og_picture,)
|
||||||
|
|
||||||
extension_sidebar_links = current_app.extension_sidebar_links(project)
|
extension_sidebar_links = current_app.extension_sidebar_links(project)
|
||||||
@@ -468,6 +516,7 @@ def view_node(project_url, node_id):
|
|||||||
show_node=True,
|
show_node=True,
|
||||||
show_project=False,
|
show_project=False,
|
||||||
og_picture=og_picture,
|
og_picture=og_picture,
|
||||||
|
navigation_links=navigation_links,
|
||||||
extension_sidebar_links=extension_sidebar_links)
|
extension_sidebar_links=extension_sidebar_links)
|
||||||
|
|
||||||
|
|
||||||
|
@@ -872,12 +872,6 @@
|
|||||||
"code": 61930,
|
"code": 61930,
|
||||||
"src": "fontawesome"
|
"src": "fontawesome"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"uid": "31972e4e9d080eaa796290349ae6c1fd",
|
|
||||||
"css": "users",
|
|
||||||
"code": 59502,
|
|
||||||
"src": "fontawesome"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"uid": "c8585e1e5b0467f28b70bce765d5840c",
|
"uid": "c8585e1e5b0467f28b70bce765d5840c",
|
||||||
"css": "clipboard-copy",
|
"css": "clipboard-copy",
|
||||||
@@ -990,6 +984,30 @@
|
|||||||
"code": 59394,
|
"code": 59394,
|
||||||
"src": "entypo"
|
"src": "entypo"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"uid": "347c38a8b96a509270fdcabc951e7571",
|
||||||
|
"css": "database",
|
||||||
|
"code": 61888,
|
||||||
|
"src": "fontawesome"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uid": "3a6f0140c3a390bdb203f56d1bfdefcb",
|
||||||
|
"css": "speed",
|
||||||
|
"code": 59471,
|
||||||
|
"src": "entypo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uid": "4c1ef492f1d2c39a2250ae457cee2a6e",
|
||||||
|
"css": "social-instagram",
|
||||||
|
"code": 61805,
|
||||||
|
"src": "fontawesome"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uid": "e36d581e4f2844db345bddc205d15dda",
|
||||||
|
"css": "users",
|
||||||
|
"code": 59507,
|
||||||
|
"src": "elusive"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"uid": "053a214a098a9453877363eeb45f004e",
|
"uid": "053a214a098a9453877363eeb45f004e",
|
||||||
"css": "log-in",
|
"css": "log-in",
|
||||||
|
Binary file not shown.
Binary file not shown.
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.
|
# TODO: put those fields into a config var or module-level global.
|
||||||
return {'email': user.email,
|
return {'email': user.email,
|
||||||
'full_name': user.full_name,
|
'full_name': user.full_name,
|
||||||
'username': user.username}
|
'username': user.username,
|
||||||
|
'badges_html': (user.badges and user.badges.html) or ''}
|
||||||
|
|
||||||
|
|
||||||
def setup_app(app):
|
def setup_app(app):
|
||||||
|
@@ -48,6 +48,10 @@ def oauth_authorize(provider):
|
|||||||
|
|
||||||
@blueprint.route('/oauth/<provider>/authorized')
|
@blueprint.route('/oauth/<provider>/authorized')
|
||||||
def oauth_callback(provider):
|
def oauth_callback(provider):
|
||||||
|
import datetime
|
||||||
|
from pillar.api.utils.authentication import store_token
|
||||||
|
from pillar.api.utils import utcnow
|
||||||
|
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
return redirect(url_for('main.homepage'))
|
return redirect(url_for('main.homepage'))
|
||||||
|
|
||||||
@@ -65,6 +69,16 @@ def oauth_callback(provider):
|
|||||||
user_info = {'id': oauth_user.id, 'email': oauth_user.email, 'full_name': ''}
|
user_info = {'id': oauth_user.id, 'email': oauth_user.email, 'full_name': ''}
|
||||||
db_user = find_user_in_db(user_info, provider=provider)
|
db_user = find_user_in_db(user_info, provider=provider)
|
||||||
db_id, status = upsert_user(db_user)
|
db_id, status = upsert_user(db_user)
|
||||||
|
|
||||||
|
# TODO(Sybren): If the user doesn't have any badges, but the access token
|
||||||
|
# does have 'badge' scope, we should fetch the badges in the background.
|
||||||
|
|
||||||
|
if oauth_user.access_token:
|
||||||
|
# TODO(Sybren): make nr of days configurable, or get from OAuthSignIn subclass.
|
||||||
|
token_expiry = utcnow() + datetime.timedelta(days=15)
|
||||||
|
token = store_token(db_id, oauth_user.access_token, token_expiry,
|
||||||
|
oauth_scopes=oauth_user.scopes)
|
||||||
|
else:
|
||||||
token = generate_and_store_token(db_id)
|
token = generate_and_store_token(db_id)
|
||||||
|
|
||||||
# Login user
|
# Login user
|
||||||
|
@@ -62,7 +62,7 @@ def jstree_get_children(node_id, project_id=None):
|
|||||||
'where': {
|
'where': {
|
||||||
'$and': [
|
'$and': [
|
||||||
{'node_type': {'$regex': '^(?!attract_)'}},
|
{'node_type': {'$regex': '^(?!attract_)'}},
|
||||||
{'node_type': {'$not': {'$in': ['comment', 'post']}}},
|
{'node_type': {'$not': {'$in': ['comment', 'post', 'blog', 'page']}}},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -6,16 +6,16 @@ algoliasearch==1.12.0
|
|||||||
bcrypt==3.1.3
|
bcrypt==3.1.3
|
||||||
blinker==1.4
|
blinker==1.4
|
||||||
bleach==2.1.3
|
bleach==2.1.3
|
||||||
celery[redis]==4.0.2
|
celery[redis]==4.2.1
|
||||||
CommonMark==0.7.2
|
CommonMark==0.7.2
|
||||||
elasticsearch==6.1.1
|
elasticsearch==6.1.1
|
||||||
elasticsearch-dsl==6.1.0
|
elasticsearch-dsl==6.1.0
|
||||||
Eve==0.7.3
|
Eve==0.8
|
||||||
Flask==0.12
|
Flask==1.0.2
|
||||||
Flask-Babel==0.11.2
|
Flask-Babel==0.11.2
|
||||||
Flask-Cache==0.13.1
|
Flask-Caching==1.4.0
|
||||||
Flask-Script==2.0.6
|
Flask-Script==2.0.6
|
||||||
Flask-Login==0.3.2
|
Flask-Login==0.4.1
|
||||||
Flask-WTF==0.14.2
|
Flask-WTF==0.14.2
|
||||||
gcloud==0.12.0
|
gcloud==0.12.0
|
||||||
google-apitools==0.4.11
|
google-apitools==0.4.11
|
||||||
@@ -27,37 +27,49 @@ Pillow==4.1.1
|
|||||||
python-dateutil==2.5.3
|
python-dateutil==2.5.3
|
||||||
rauth==0.7.3
|
rauth==0.7.3
|
||||||
raven[flask]==6.3.0
|
raven[flask]==6.3.0
|
||||||
|
requests==2.13.0
|
||||||
redis==2.10.5
|
redis==2.10.5
|
||||||
shortcodes==2.5.0
|
shortcodes==2.5.0
|
||||||
WebOb==1.5.0
|
WebOb==1.5.0
|
||||||
wheel==0.29.0
|
wheel==0.29.0
|
||||||
zencoder==0.6.5
|
zencoder==0.6.5
|
||||||
|
|
||||||
|
|
||||||
# Secondary requirements
|
# Secondary requirements
|
||||||
amqp==2.1.4
|
amqp==2.3.2
|
||||||
billiard==3.5.0.2
|
asn1crypto==0.24.0
|
||||||
Flask-PyMongo==0.4.1
|
Babel==2.6.0
|
||||||
-e git+https://github.com/armadillica/cerberus.git@sybren-0.9#egg=Cerberus
|
billiard==3.5.0.4
|
||||||
Events==0.2.2
|
Cerberus==1.2
|
||||||
future==0.15.2
|
cffi==1.10.0
|
||||||
html5lib==0.99999999
|
click==6.7
|
||||||
googleapis-common-protos==1.1.0
|
cryptography==2.0.3
|
||||||
|
Events==0.3
|
||||||
|
future==0.16.0
|
||||||
|
googleapis-common-protos==1.5.3
|
||||||
|
html5lib==1.0.1
|
||||||
|
idna==2.5
|
||||||
|
ipaddress==1.0.22
|
||||||
itsdangerous==0.24
|
itsdangerous==0.24
|
||||||
Jinja2==2.9.6
|
Jinja2==2.10
|
||||||
kombu==4.0.2
|
kombu==4.2.1
|
||||||
oauth2client==2.0.2
|
oauth2client==4.1.2
|
||||||
oauthlib==2.0.1
|
oauthlib==2.1.0
|
||||||
olefile==0.44
|
olefile==0.45.1
|
||||||
protobuf==3.0.0b2.post2
|
protobuf==3.6.0
|
||||||
protorpc==0.11.1
|
protorpc==0.12.0
|
||||||
pyasn1-modules==0.0.8
|
pyasn1==0.4.4
|
||||||
pymongo==3.4.0
|
pyasn1-modules==0.2.2
|
||||||
pytz==2017.2
|
pycparser==2.17
|
||||||
requests-oauthlib==0.7.0
|
pymongo==3.7.0
|
||||||
|
pyOpenSSL==16.2.0
|
||||||
|
pytz==2018.5
|
||||||
|
requests-oauthlib==1.0.0
|
||||||
rsa==3.4.2
|
rsa==3.4.2
|
||||||
simplejson==3.10.0
|
simplejson==3.16.0
|
||||||
six==1.10.0
|
six==1.10.0
|
||||||
urllib3==1.22
|
urllib3==1.22
|
||||||
vine==1.1.3
|
vine==1.1.4
|
||||||
WTForms==2.1
|
webencodings==0.5.1
|
||||||
Werkzeug==0.11.15
|
Werkzeug==0.14.1
|
||||||
|
WTForms==2.2.1
|
||||||
|
2
setup.py
2
setup.py
@@ -35,7 +35,7 @@ setuptools.setup(
|
|||||||
install_requires=[
|
install_requires=[
|
||||||
'Flask>=0.12',
|
'Flask>=0.12',
|
||||||
'Eve>=0.7.3',
|
'Eve>=0.7.3',
|
||||||
'Flask-Cache>=0.13.1',
|
'Flask-Caching>=1.4.0',
|
||||||
'Flask-Script>=2.0.5',
|
'Flask-Script>=2.0.5',
|
||||||
'Flask-Login>=0.3.2',
|
'Flask-Login>=0.3.2',
|
||||||
'Flask-OAuthlib>=0.9.3',
|
'Flask-OAuthlib>=0.9.3',
|
||||||
|
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";
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
})();
|
|
||||||
|
|
@@ -32,7 +32,7 @@ var DocumentTitleAPI = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/* Status Bar */
|
/* Status Bar * DEPRECATED * USE TOASTR INSTEAD */
|
||||||
function statusBarClear(delay_class, delay_html){
|
function statusBarClear(delay_class, delay_html){
|
||||||
var statusBar = $("#status-bar");
|
var statusBar = $("#status-bar");
|
||||||
|
|
||||||
@@ -54,6 +54,7 @@ function statusBarClear(delay_class, delay_html){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Status Bar * DEPRECATED - USE TOASTR INSTEAD * */
|
||||||
function statusBarSet(classes, html, icon_name, time){
|
function statusBarSet(classes, html, icon_name, time){
|
||||||
/* Utility to notify the user by temporarily flashing text on the project header
|
/* Utility to notify the user by temporarily flashing text on the project header
|
||||||
Usage:
|
Usage:
|
||||||
|
@@ -66,12 +66,9 @@ function containerResizeY(window_height){
|
|||||||
|
|
||||||
var project_container = document.getElementById('project-container');
|
var project_container = document.getElementById('project-container');
|
||||||
var container_offset = project_container.offsetTop;
|
var container_offset = project_container.offsetTop;
|
||||||
var nav_header_height = $('#project_nav-header').height();
|
|
||||||
var container_height = window_height - container_offset.top;
|
var container_height = window_height - container_offset.top;
|
||||||
var container_height_wheader = window_height - container_offset.top - nav_header_height;
|
var container_height_wheader = window_height - container_offset;
|
||||||
var window_height_minus_nav = window_height - nav_header_height - 1; // 1 is border width
|
var window_height_minus_nav = window_height - container_offset;
|
||||||
|
|
||||||
$('#project_context-header').width($('#project_context-container').width());
|
|
||||||
|
|
||||||
if ($(window).width() > 768) {
|
if ($(window).width() > 768) {
|
||||||
$('#project-container').css(
|
$('#project-container').css(
|
||||||
@@ -79,13 +76,14 @@ function containerResizeY(window_height){
|
|||||||
'height': window_height_minus_nav + 'px'}
|
'height': window_height_minus_nav + 'px'}
|
||||||
);
|
);
|
||||||
|
|
||||||
$('#project_nav-container, #project_tree, .project_split').css(
|
$('#project_nav-container, #project_tree').css(
|
||||||
{'max-height': (window_height_minus_nav - 50) + 'px',
|
{'max-height': (window_height_minus_nav) + 'px',
|
||||||
'height': (window_height_minus_nav - 50) + 'px'}
|
'height': (window_height_minus_nav) + 'px'}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (container_height > parseInt($('#project-container').css("min-height"))) {
|
if (container_height > parseInt($('#project-container').css("min-height"))) {
|
||||||
if (typeof projectTree !== "undefined"){
|
if (typeof projectTree !== "undefined"){
|
||||||
|
|
||||||
$(projectTree).css(
|
$(projectTree).css(
|
||||||
{'max-height': container_height_wheader + 'px',
|
{'max-height': container_height_wheader + 'px',
|
||||||
'height': container_height_wheader + 'px'}
|
'height': container_height_wheader + 'px'}
|
||||||
|
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
|
left: 0
|
||||||
width: $sidebar-width
|
width: $sidebar-width
|
||||||
height: 100%
|
height: 100%
|
||||||
background-color: $color-background-nav
|
|
||||||
display: flex
|
display: flex
|
||||||
flex-direction: column
|
flex-direction: column
|
||||||
|
|
||||||
|
> ul > li > .navbar-item
|
||||||
|
padding-top: 10px
|
||||||
|
padding-bottom: 10px
|
||||||
|
background: red
|
||||||
|
|
||||||
.dropdown
|
.dropdown
|
||||||
min-width: $sidebar-width
|
min-width: $sidebar-width
|
||||||
|
|
||||||
.dropdown-menu
|
.dropdown-menu
|
||||||
top: initial
|
top: initial
|
||||||
bottom: 3px
|
bottom: 3px
|
||||||
@@ -159,7 +164,7 @@ nav.sidebar
|
|||||||
li a
|
li a
|
||||||
justify-content: flex-start
|
justify-content: flex-start
|
||||||
|
|
||||||
ul
|
> ul
|
||||||
width: 100%
|
width: 100%
|
||||||
margin: 0
|
margin: 0
|
||||||
padding: 0
|
padding: 0
|
||||||
@@ -172,25 +177,11 @@ nav.sidebar
|
|||||||
|
|
||||||
a.navbar-item, button
|
a.navbar-item, button
|
||||||
display: flex
|
display: flex
|
||||||
color: $color-text-light-hint
|
|
||||||
font-size: 1.5em
|
|
||||||
align-items: center
|
align-items: center
|
||||||
justify-content: center
|
justify-content: center
|
||||||
padding: 10px 0
|
|
||||||
background: transparent
|
background: transparent
|
||||||
border: none
|
border: none
|
||||||
width: 100%
|
width: 100%
|
||||||
text-decoration: none
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
color: $color-text-light-primary
|
|
||||||
&:active
|
|
||||||
outline: none
|
|
||||||
|
|
||||||
&.cloud
|
|
||||||
i
|
|
||||||
position: relative
|
|
||||||
left: -4px
|
|
||||||
|
|
||||||
a.dropdown-toggle
|
a.dropdown-toggle
|
||||||
padding: 0
|
padding: 0
|
||||||
@@ -408,3 +399,68 @@ nav.sidebar
|
|||||||
top: -1px
|
top: -1px
|
||||||
left: -19px
|
left: -19px
|
||||||
z-index: 1
|
z-index: 1
|
||||||
|
|
||||||
|
$loader-bar-width: 100px
|
||||||
|
$loader-bar-height: 2px
|
||||||
|
.loader-bar
|
||||||
|
bottom: 0
|
||||||
|
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-width-max: 710px
|
||||||
|
|
||||||
.comments-container
|
.comments-container
|
||||||
|
max-width: $comments-width-max
|
||||||
position: relative
|
position: relative
|
||||||
|
width: 100%
|
||||||
|
|
||||||
#comments-reload
|
#comments-reload
|
||||||
text-align: center
|
text-align: center
|
||||||
@@ -314,9 +316,6 @@ $comments-width-max: 710px
|
|||||||
color: $color-success
|
color: $color-success
|
||||||
|
|
||||||
.comment-reply
|
.comment-reply
|
||||||
&-container
|
|
||||||
background-color: $color-background
|
|
||||||
|
|
||||||
/* Little gravatar icon on the left */
|
/* Little gravatar icon on the left */
|
||||||
&-avatar
|
&-avatar
|
||||||
img
|
img
|
||||||
@@ -333,7 +332,7 @@ $comments-width-max: 710px
|
|||||||
width: 100%
|
width: 100%
|
||||||
|
|
||||||
&-field
|
&-field
|
||||||
background-color: $color-background-dark
|
background-color: $color-background-light
|
||||||
border-radius: 3px
|
border-radius: 3px
|
||||||
box-shadow: inset 0 0 2px 0 rgba(darken($color-background-dark, 20%), .5)
|
box-shadow: inset 0 0 2px 0 rgba(darken($color-background-dark, 20%), .5)
|
||||||
display: flex
|
display: flex
|
||||||
@@ -342,6 +341,7 @@ $comments-width-max: 710px
|
|||||||
|
|
||||||
textarea
|
textarea
|
||||||
+node-details-description
|
+node-details-description
|
||||||
|
background-color: $color-background-light
|
||||||
border-bottom-right-radius: 0
|
border-bottom-right-radius: 0
|
||||||
border-top-right-radius: 0
|
border-top-right-radius: 0
|
||||||
border: none
|
border: none
|
||||||
@@ -376,7 +376,6 @@ $comments-width-max: 710px
|
|||||||
|
|
||||||
&.filled
|
&.filled
|
||||||
textarea
|
textarea
|
||||||
background-color: $color-background-light
|
|
||||||
border-bottom: thin solid $color-background
|
border-bottom: thin solid $color-background
|
||||||
|
|
||||||
&:focus
|
&:focus
|
||||||
|
@@ -12,9 +12,10 @@ $color-background-active-dark: hsl(hue($color-background-active), 50%, 50%) !def
|
|||||||
$font-body: 'Roboto' !default
|
$font-body: 'Roboto' !default
|
||||||
$font-headings: 'Lato' !default
|
$font-headings: 'Lato' !default
|
||||||
$font-size: 14px !default
|
$font-size: 14px !default
|
||||||
|
$font-size-xs: .75rem
|
||||||
|
$font-size-xxs: .65rem
|
||||||
|
|
||||||
$color-text: #4d4e53 !default
|
$color-text: #4d4e53 !default
|
||||||
|
|
||||||
$color-text-dark: $color-text !default
|
$color-text-dark: $color-text !default
|
||||||
$color-text-dark-primary: #646469 !default
|
$color-text-dark-primary: #646469 !default
|
||||||
$color-text-dark-secondary: #9E9FA2 !default
|
$color-text-dark-secondary: #9E9FA2 !default
|
||||||
@@ -29,6 +30,7 @@ $color-primary: #009eff !default
|
|||||||
$color-primary-light: hsl(hue($color-primary), 30%, 90%) !default
|
$color-primary-light: hsl(hue($color-primary), 30%, 90%) !default
|
||||||
$color-primary-dark: hsl(hue($color-primary), 80%, 30%) !default
|
$color-primary-dark: hsl(hue($color-primary), 80%, 30%) !default
|
||||||
$color-primary-accent: hsl(hue($color-primary), 100%, 50%) !default
|
$color-primary-accent: hsl(hue($color-primary), 100%, 50%) !default
|
||||||
|
$primary-accent: #0bd
|
||||||
|
|
||||||
$color-secondary: #f42942 !default
|
$color-secondary: #f42942 !default
|
||||||
$color-secondary-light: hsl(hue($color-secondary), 30%, 90%) !default
|
$color-secondary-light: hsl(hue($color-secondary), 30%, 90%) !default
|
||||||
@@ -96,13 +98,16 @@ $screen-xs-max: $screen-sm-min - 1 !default
|
|||||||
$screen-sm-max: $screen-md-min - 1 !default
|
$screen-sm-max: $screen-md-min - 1 !default
|
||||||
$screen-md-max: $screen-lg-min - 1 !default
|
$screen-md-max: $screen-lg-min - 1 !default
|
||||||
|
|
||||||
$sidebar-width: 50px !default
|
$sidebar-width: 40px !default
|
||||||
|
|
||||||
/* Project specifics */
|
/* Project specifics */
|
||||||
$project_nav-width: 250px !default
|
$project_nav-width: 275px !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-sidebar-width: 40px !default
|
||||||
$project_header-height: 37px !default
|
$project_header-height: 40px !default
|
||||||
$project_footer-height: 30px !default
|
|
||||||
|
|
||||||
$node-type-asset_image: #e87d86 !default
|
$node-type-asset_image: #e87d86 !default
|
||||||
$node-type-asset_file: #CC91C7 !default
|
$node-type-asset_file: #CC91C7 !default
|
||||||
@@ -143,8 +148,16 @@ $font-size-base: .9rem
|
|||||||
|
|
||||||
$dropdown-border-width: 0
|
$dropdown-border-width: 0
|
||||||
$dropdown-box-shadow: 0 10px 25px rgba($black, .1)
|
$dropdown-box-shadow: 0 10px 25px rgba($black, .1)
|
||||||
|
$dropdown-padding-y: 0
|
||||||
|
$dropdown-item-padding-y: .4rem
|
||||||
|
|
||||||
// Tooltips.
|
// Tooltips.
|
||||||
$tooltip-font-size: 0.83rem
|
$tooltip-font-size: 0.83rem
|
||||||
$tooltip-max-width: auto
|
$tooltip-max-width: auto
|
||||||
$tooltip-opacity: 1
|
$tooltip-opacity: 1
|
||||||
|
|
||||||
|
$nav-link-height: 37px
|
||||||
|
$navbar-padding-x: 0
|
||||||
|
$navbar-padding-y: 0
|
||||||
|
|
||||||
|
$grid-breakpoints: (xs: 0,sm: 576px,md: 768px,lg: 1100px,xl: 1500px, xxl: 1800px)
|
||||||
|
@@ -24,13 +24,16 @@
|
|||||||
color: $color-secondary
|
color: $color-secondary
|
||||||
|
|
||||||
#notifications-toggle
|
#notifications-toggle
|
||||||
|
color: $color-text
|
||||||
cursor: pointer
|
cursor: pointer
|
||||||
font-size: 1.5em
|
|
||||||
position: relative
|
position: relative
|
||||||
user-select: none
|
user-select: none
|
||||||
|
|
||||||
> i:before
|
> i:before
|
||||||
content: '\e815'
|
content: '\e815'
|
||||||
|
font-size: 1.3em
|
||||||
|
position: relative
|
||||||
|
top: 2px
|
||||||
|
|
||||||
&.has-notifications
|
&.has-notifications
|
||||||
> i:before
|
> i:before
|
||||||
@@ -45,10 +48,10 @@
|
|||||||
border-color: transparent transparent $color-background transparent
|
border-color: transparent transparent $color-background transparent
|
||||||
border-style: solid
|
border-style: solid
|
||||||
border-width: 0 8px 8px 8px
|
border-width: 0 8px 8px 8px
|
||||||
bottom: -15px
|
bottom: -10px
|
||||||
height: 0
|
height: 0
|
||||||
position: absolute
|
position: absolute
|
||||||
right: 22px
|
right: 7px
|
||||||
visibility: hidden
|
visibility: hidden
|
||||||
width: 0
|
width: 0
|
||||||
|
|
||||||
|
@@ -93,15 +93,6 @@ ul.sharing-users-list
|
|||||||
color: lighten($color-danger, 10%)
|
color: lighten($color-danger, 10%)
|
||||||
|
|
||||||
|
|
||||||
.sharing-users-info
|
|
||||||
padding-left: 15px
|
|
||||||
border-left: thin solid $color-text-dark-hint
|
|
||||||
|
|
||||||
p
|
|
||||||
font:
|
|
||||||
size: 1.1em
|
|
||||||
weight: 300
|
|
||||||
|
|
||||||
.sharing-users-search
|
.sharing-users-search
|
||||||
.disabled
|
.disabled
|
||||||
color: $color-text-dark-secondary
|
color: $color-text-dark-secondary
|
||||||
@@ -158,24 +149,26 @@ ul.list-generic
|
|||||||
list-style: none
|
list-style: none
|
||||||
|
|
||||||
> li
|
> li
|
||||||
padding: 5px 0
|
|
||||||
display: flex
|
|
||||||
align-items: center
|
align-items: center
|
||||||
border-top: thin solid $color-background
|
border-top: thin solid $color-background
|
||||||
|
display: flex
|
||||||
|
padding: 5px 0
|
||||||
|
|
||||||
&:first-child
|
&:first-child
|
||||||
border-top: none
|
border-top: none
|
||||||
|
|
||||||
&:hover .item a
|
&:hover .item a
|
||||||
color: $color-primary
|
color: $primary
|
||||||
|
|
||||||
a
|
a
|
||||||
flex: 1
|
flex: 1
|
||||||
|
|
||||||
|
&.active
|
||||||
|
color: $primary !important
|
||||||
|
font-weight: bold
|
||||||
|
|
||||||
.actions
|
.actions
|
||||||
margin-left: auto
|
margin-left: auto
|
||||||
.btn
|
|
||||||
font-size: .7em
|
|
||||||
|
|
||||||
span
|
span
|
||||||
color: $color-text-dark-secondary
|
color: $color-text-dark-secondary
|
||||||
|
@@ -6,24 +6,12 @@ body.workshops
|
|||||||
#project-container
|
#project-container
|
||||||
+container-behavior
|
+container-behavior
|
||||||
|
|
||||||
body.blog
|
|
||||||
background-color: white
|
|
||||||
|
|
||||||
#project_nav,
|
|
||||||
#project_nav-container
|
|
||||||
+media-md
|
|
||||||
width: $project_nav-width * 1.4
|
|
||||||
+media-sm
|
|
||||||
width: $project_nav-width * 1.1
|
|
||||||
+media-xs
|
|
||||||
width: 100%
|
|
||||||
width: $project_nav-width * 1.5
|
|
||||||
|
|
||||||
#project-container
|
#project-container
|
||||||
display: flex
|
display: flex
|
||||||
flex-direction: row
|
flex-direction: row
|
||||||
min-height: 300px
|
min-height: 300px
|
||||||
position: relative
|
position: relative
|
||||||
|
overflow-y: auto
|
||||||
z-index: $z-index-base
|
z-index: $z-index-base
|
||||||
|
|
||||||
+media-xs
|
+media-xs
|
||||||
@@ -36,14 +24,19 @@ body.blog
|
|||||||
+media-xs
|
+media-xs
|
||||||
flex-direction: column-reverse
|
flex-direction: column-reverse
|
||||||
|
|
||||||
#project-side-container+#project_context-header
|
|
||||||
width: 100%
|
|
||||||
left: 0
|
|
||||||
|
|
||||||
|
#project_nav,
|
||||||
#project_nav-container
|
#project_nav-container
|
||||||
+media-lg
|
+media-lg
|
||||||
width: $project_nav-width * 1.33
|
width: $project_nav-width-lg
|
||||||
|
+media-sm
|
||||||
|
width: $project_nav-width-sm
|
||||||
|
+media-xs
|
||||||
|
width: $project_nav-width-xs
|
||||||
|
|
||||||
|
width: $project_nav-width
|
||||||
|
|
||||||
|
#project_nav-container
|
||||||
+media-xs
|
+media-xs
|
||||||
display: block
|
display: block
|
||||||
width: 100%
|
width: 100%
|
||||||
@@ -52,8 +45,6 @@ body.blog
|
|||||||
|
|
||||||
position: fixed
|
position: fixed
|
||||||
z-index: $z-index-base + 5
|
z-index: $z-index-base + 5
|
||||||
width: $project_nav-width
|
|
||||||
|
|
||||||
|
|
||||||
#project_sidebar
|
#project_sidebar
|
||||||
width: $project-sidebar-width
|
width: $project-sidebar-width
|
||||||
@@ -80,24 +71,14 @@ body.blog
|
|||||||
height: $project-sidebar-width
|
height: $project-sidebar-width
|
||||||
position: relative
|
position: relative
|
||||||
|
|
||||||
+media-xs
|
|
||||||
border-bottom: 2px solid transparent
|
|
||||||
+media-sm
|
|
||||||
border-bottom: 2px solid transparent
|
|
||||||
+media-md
|
|
||||||
border-left: 2px solid transparent
|
|
||||||
+media-lg
|
|
||||||
border-left: 2px solid transparent
|
|
||||||
|
|
||||||
&:first-child
|
|
||||||
border-top: thin solid transparent
|
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&.active
|
&.active
|
||||||
cursor: pointer
|
cursor: pointer
|
||||||
|
|
||||||
a
|
a
|
||||||
color: $primary
|
color: $primary
|
||||||
|
i
|
||||||
|
+active-gradient
|
||||||
|
|
||||||
a
|
a
|
||||||
align-items: center
|
align-items: center
|
||||||
@@ -112,13 +93,6 @@ body.blog
|
|||||||
height: $project-sidebar-width
|
height: $project-sidebar-width
|
||||||
width: $project-sidebar-width
|
width: $project-sidebar-width
|
||||||
|
|
||||||
#project-loading
|
|
||||||
align-items: center
|
|
||||||
display: flex
|
|
||||||
height: $project-sidebar-width
|
|
||||||
justify-content: center
|
|
||||||
width: $project-sidebar-width
|
|
||||||
|
|
||||||
|
|
||||||
#search-container #project_sidebar ul.project-tabs li.tabs-thumbnail
|
#search-container #project_sidebar ul.project-tabs li.tabs-thumbnail
|
||||||
background-color: $color-background-nav-dark
|
background-color: $color-background-nav-dark
|
||||||
@@ -133,10 +107,11 @@ body.blog
|
|||||||
/* Container for navigation on the left */
|
/* Container for navigation on the left */
|
||||||
#project_nav
|
#project_nav
|
||||||
+media-lg
|
+media-lg
|
||||||
width: $project_nav-width * 1.33
|
width: $project_nav-width-lg
|
||||||
|
+media-sm
|
||||||
|
width: $project_nav-width-sm
|
||||||
+media-xs
|
+media-xs
|
||||||
width: initial
|
width: $project_nav-width-xs
|
||||||
|
|
||||||
display: block
|
display: block
|
||||||
left: 0
|
left: 0
|
||||||
@@ -150,86 +125,10 @@ body.blog
|
|||||||
|
|
||||||
|
|
||||||
/* Header with name and node edit tools */
|
/* Header with name and node edit tools */
|
||||||
#project_nav-header,
|
|
||||||
#project_context-header
|
#project_context-header
|
||||||
align-items: center
|
right: 0
|
||||||
color: white
|
|
||||||
display: flex
|
|
||||||
position: relative
|
|
||||||
|
|
||||||
#project_context-header
|
|
||||||
align-items: center
|
|
||||||
color: $color-text-dark-secondary
|
|
||||||
display: flex
|
|
||||||
height: $project_header-height
|
|
||||||
min-height: $project_header-height
|
|
||||||
position: fixed
|
|
||||||
top: $project_header-height + 1
|
|
||||||
transition: box-shadow 250ms ease-in-out
|
|
||||||
z-index: $z-index-base + 3
|
z-index: $z-index-base + 3
|
||||||
|
|
||||||
&.is-offset
|
|
||||||
box-shadow: 0 0 25px rgba(black, .2)
|
|
||||||
|
|
||||||
span#status-bar
|
|
||||||
text-align: left
|
|
||||||
|
|
||||||
#project_nav-header
|
|
||||||
left: 0
|
|
||||||
position: absolute
|
|
||||||
top: 0
|
|
||||||
width: 100%
|
|
||||||
z-index: $z-index-base + 3
|
|
||||||
|
|
||||||
/* Name of the project */
|
|
||||||
.project-title
|
|
||||||
+text-overflow-ellipsis
|
|
||||||
height: 100%
|
|
||||||
max-width: 100%
|
|
||||||
width: 100%
|
|
||||||
|
|
||||||
span#status-bar
|
|
||||||
position: absolute
|
|
||||||
left: 0
|
|
||||||
top: 10px
|
|
||||||
min-width: 33%
|
|
||||||
padding: 5px 15px
|
|
||||||
color: $color-text-dark
|
|
||||||
opacity: 0
|
|
||||||
z-index: 1
|
|
||||||
font-weight: 400
|
|
||||||
white-space: nowrap
|
|
||||||
transition: all 250ms ease-in-out
|
|
||||||
|
|
||||||
+media-xs
|
|
||||||
left: 0
|
|
||||||
min-width: 100%
|
|
||||||
|
|
||||||
i
|
|
||||||
margin-right: 5px
|
|
||||||
|
|
||||||
&.info
|
|
||||||
color: $color-info
|
|
||||||
&.error
|
|
||||||
color: $danger
|
|
||||||
&.warning
|
|
||||||
color: $color-warning
|
|
||||||
&.success
|
|
||||||
color: $color-success
|
|
||||||
&.default
|
|
||||||
color: $color-text-light
|
|
||||||
|
|
||||||
&.active
|
|
||||||
opacity: 1
|
|
||||||
|
|
||||||
span#project-edit-title
|
|
||||||
position: absolute
|
|
||||||
padding: 15px 20px
|
|
||||||
font:
|
|
||||||
size: 1.1em
|
|
||||||
weight: 400
|
|
||||||
white-space: nowrap
|
|
||||||
|
|
||||||
|
|
||||||
/* Edit Asset buttons */
|
/* Edit Asset buttons */
|
||||||
.project-mode-view,
|
.project-mode-view,
|
||||||
@@ -238,24 +137,16 @@ span#project-edit-title
|
|||||||
display: none
|
display: none
|
||||||
|
|
||||||
ul.project-edit-tools
|
ul.project-edit-tools
|
||||||
align-items: center
|
li:not(.disabled)
|
||||||
display: flex
|
.btn
|
||||||
list-style-type: none
|
background-color: $white
|
||||||
margin: 0 0 0 auto
|
|
||||||
padding: 0
|
|
||||||
|
|
||||||
+media-xs
|
&:hover
|
||||||
width: 100%
|
border-color: $primary
|
||||||
margin: 0 auto
|
background-color: $white
|
||||||
justify-content: space-around
|
color: $primary
|
||||||
|
|
||||||
li
|
li
|
||||||
a, button
|
|
||||||
padding: $dropdown-item-padding-y ($dropdown-item-padding-x / 2)
|
|
||||||
|
|
||||||
i
|
|
||||||
padding-right: 10px
|
|
||||||
|
|
||||||
&.button-save
|
&.button-save
|
||||||
&.field-error
|
&.field-error
|
||||||
a
|
a
|
||||||
@@ -283,10 +174,6 @@ ul.project-edit-tools
|
|||||||
cursor: default
|
cursor: default
|
||||||
+pulse-75
|
+pulse-75
|
||||||
|
|
||||||
&.button-add,
|
|
||||||
&.button-edit
|
|
||||||
min-width: 80px
|
|
||||||
|
|
||||||
&.featured
|
&.featured
|
||||||
a
|
a
|
||||||
color: $color-warning
|
color: $color-warning
|
||||||
@@ -303,8 +190,6 @@ ul.project-edit-tools
|
|||||||
|
|
||||||
&.dropdown
|
&.dropdown
|
||||||
li
|
li
|
||||||
padding: 0
|
|
||||||
|
|
||||||
a
|
a
|
||||||
color: $body-color
|
color: $body-color
|
||||||
display: block
|
display: block
|
||||||
@@ -336,81 +221,6 @@ ul.project-edit-tools
|
|||||||
/* // Extra asset tools in dropdown */
|
/* // Extra asset tools in dropdown */
|
||||||
/* // Edit Asset buttons */
|
/* // Edit Asset buttons */
|
||||||
|
|
||||||
#project-loading
|
|
||||||
color: $color-text-light-primary
|
|
||||||
margin-left: auto
|
|
||||||
display: inline-block
|
|
||||||
background-color: transparent
|
|
||||||
opacity: 0
|
|
||||||
transition: opacity 150ms ease-in-out
|
|
||||||
+position-center-translate
|
|
||||||
|
|
||||||
i
|
|
||||||
position: relative
|
|
||||||
top: 2px
|
|
||||||
|
|
||||||
&.active
|
|
||||||
background-color: rgba($color-background-nav-dark, .7)
|
|
||||||
opacity: 1
|
|
||||||
|
|
||||||
i
|
|
||||||
+spin
|
|
||||||
animation-duration: .5s
|
|
||||||
|
|
||||||
#project-header+#project_tree,
|
|
||||||
#project-header+#project_context-container
|
|
||||||
margin-top: $project_header-height
|
|
||||||
|
|
||||||
|
|
||||||
ul.project_nav-edit-list
|
|
||||||
list-style: none
|
|
||||||
padding: 0
|
|
||||||
margin: $project_header-height 0 0 0
|
|
||||||
|
|
||||||
li
|
|
||||||
background-color: $color-background
|
|
||||||
border-bottom: 1px solid $color-background-dark
|
|
||||||
color: $color-text-dark
|
|
||||||
position: relative
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
cursor: pointer
|
|
||||||
background-color: $color-background-light
|
|
||||||
a
|
|
||||||
padding: 10px 15px
|
|
||||||
display: inline-block
|
|
||||||
width: 100%
|
|
||||||
text-decoration: none
|
|
||||||
color: $color-text-dark
|
|
||||||
|
|
||||||
i
|
|
||||||
padding-right: 15px
|
|
||||||
|
|
||||||
&.active
|
|
||||||
background-color: white
|
|
||||||
|
|
||||||
a
|
|
||||||
color: $color-primary-dark
|
|
||||||
|
|
||||||
.project_nav-toggle-btn
|
|
||||||
position: absolute
|
|
||||||
bottom: 0
|
|
||||||
width: 100%
|
|
||||||
padding: 10px
|
|
||||||
text-align: center
|
|
||||||
color: $color-text-light-hint
|
|
||||||
cursor: pointer
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
color: $color-text-light
|
|
||||||
|
|
||||||
i
|
|
||||||
font-size: 1.3em
|
|
||||||
|
|
||||||
+media-xs
|
|
||||||
visibility: hidden
|
|
||||||
display: none
|
|
||||||
|
|
||||||
#project_context
|
#project_context
|
||||||
position: relative
|
position: relative
|
||||||
display: flex
|
display: flex
|
||||||
@@ -419,13 +229,7 @@ ul.project_nav-edit-list
|
|||||||
height: 100%
|
height: 100%
|
||||||
background-color: white
|
background-color: white
|
||||||
|
|
||||||
#project_context-header+#project_context
|
|
||||||
padding-top: $project_header-height
|
|
||||||
|
|
||||||
#node-container
|
#node-container
|
||||||
background-color: white
|
|
||||||
flex: 1
|
|
||||||
|
|
||||||
/* For error messages (403) and other overlaid text*/
|
/* For error messages (403) and other overlaid text*/
|
||||||
#node-overlay
|
#node-overlay
|
||||||
z-index: $z-index-base + 2
|
z-index: $z-index-base + 2
|
||||||
@@ -456,16 +260,13 @@ ul.project_nav-edit-list
|
|||||||
#project_tree
|
#project_tree
|
||||||
+media-xs
|
+media-xs
|
||||||
margin-top: 0
|
margin-top: 0
|
||||||
|
|
||||||
// margin-top: $project_header-height //so it's right below the project title.
|
|
||||||
overflow-y: auto // show vertical scrollbars when needed.
|
overflow-y: auto // show vertical scrollbars when needed.
|
||||||
padding: 0 0 5px 0 // some padding on top/bottom of jstree.
|
padding: 5px 0 // some padding on top/bottom of jsTree.
|
||||||
position: relative
|
position: relative
|
||||||
|
|
||||||
&.edit
|
&.edit
|
||||||
margin-top: 0
|
margin-top: 0
|
||||||
|
|
||||||
|
|
||||||
/* Node Context */
|
/* Node Context */
|
||||||
=project-node-title
|
=project-node-title
|
||||||
font-size: 1.5em
|
font-size: 1.5em
|
||||||
@@ -701,12 +502,11 @@ section.node-preview
|
|||||||
align-items: center
|
align-items: center
|
||||||
background-color: black
|
background-color: black
|
||||||
color: $color-text-light-primary
|
color: $color-text-light-primary
|
||||||
display: flex
|
// display: flex
|
||||||
flex: 1
|
|
||||||
justify-content: center
|
justify-content: center
|
||||||
max-height: 500px
|
max-height: 500px
|
||||||
min-height: 200px
|
// min-height: 200px
|
||||||
overflow: hidden
|
// overflow: hidden
|
||||||
|
|
||||||
iframe
|
iframe
|
||||||
width: 100%
|
width: 100%
|
||||||
@@ -727,6 +527,8 @@ section.node-preview
|
|||||||
|
|
||||||
&.image
|
&.image
|
||||||
cursor: zoom-in
|
cursor: zoom-in
|
||||||
|
display: flex
|
||||||
|
overflow: hidden
|
||||||
|
|
||||||
&.video
|
&.video
|
||||||
background-color: black
|
background-color: black
|
||||||
@@ -781,12 +583,6 @@ section.node-preview
|
|||||||
color: $color-warning
|
color: $color-warning
|
||||||
margin-right: 10px
|
margin-right: 10px
|
||||||
|
|
||||||
&.group
|
|
||||||
align-items: center
|
|
||||||
display: flex
|
|
||||||
padding: 20px
|
|
||||||
position: relative
|
|
||||||
|
|
||||||
&.project
|
&.project
|
||||||
background-color: black
|
background-color: black
|
||||||
width: 100%
|
width: 100%
|
||||||
@@ -831,11 +627,6 @@ section.node-preview-forbidden
|
|||||||
hr
|
hr
|
||||||
opacity: .5
|
opacity: .5
|
||||||
|
|
||||||
section.node-details-container
|
|
||||||
background-color: white
|
|
||||||
|
|
||||||
&.project
|
|
||||||
padding-bottom: 15px
|
|
||||||
|
|
||||||
/* Narrower details for about page (since it doesn't have navtree) */
|
/* Narrower details for about page (since it doesn't have navtree) */
|
||||||
body.about
|
body.about
|
||||||
@@ -847,8 +638,7 @@ body.about
|
|||||||
.node-title
|
.node-title
|
||||||
+project-node-title
|
+project-node-title
|
||||||
|
|
||||||
section.node-details-container,
|
section.node-details-container
|
||||||
section.node-preview.group
|
|
||||||
position: relative
|
position: relative
|
||||||
|
|
||||||
.node-details-header
|
.node-details-header
|
||||||
@@ -861,46 +651,17 @@ section.node-preview.group
|
|||||||
width: 100%
|
width: 100%
|
||||||
max-width: 100%
|
max-width: 100%
|
||||||
|
|
||||||
.node-details-meta-actions
|
|
||||||
margin-left: auto
|
|
||||||
|
|
||||||
.btn-browsetoggle
|
|
||||||
+button(lighten($color-background-nav, 20%), 3px)
|
|
||||||
width: 48px
|
|
||||||
text-align: center
|
|
||||||
padding: 2px 4px
|
|
||||||
i
|
|
||||||
font-size: 1.3em
|
|
||||||
padding: 0
|
|
||||||
margin: 0
|
|
||||||
|
|
||||||
|
|
||||||
.node-details-description
|
|
||||||
+node-details-description
|
|
||||||
padding-left: 20px
|
|
||||||
padding-right: 20px
|
|
||||||
|
|
||||||
.node-details-meta
|
.node-details-meta
|
||||||
background-color: $color-background-light
|
|
||||||
border-bottom: thin solid $color-background
|
|
||||||
display: flex
|
|
||||||
flex-direction: column
|
|
||||||
font-weight: lighter
|
|
||||||
padding: 10px 20px
|
|
||||||
|
|
||||||
> ul
|
> ul
|
||||||
align-items: center
|
align-items: center
|
||||||
display: flex
|
display: flex
|
||||||
list-style-type: none
|
|
||||||
margin: 0
|
|
||||||
padding: 0
|
|
||||||
|
|
||||||
> li
|
> li
|
||||||
align-items: baseline
|
align-items: baseline
|
||||||
color: $color-text-dark-secondary
|
color: $color-text-dark-secondary
|
||||||
display: flex
|
display: flex
|
||||||
padding-left: 10px
|
padding-left: 5px
|
||||||
margin-left: 10px
|
margin-left: 5px
|
||||||
|
|
||||||
&:first-child
|
&:first-child
|
||||||
margin-left: 0
|
margin-left: 0
|
||||||
@@ -913,55 +674,31 @@ section.node-preview.group
|
|||||||
color: $color-success
|
color: $color-success
|
||||||
|
|
||||||
&.download
|
&.download
|
||||||
position: relative
|
|
||||||
|
|
||||||
button
|
|
||||||
+button($color-success, 3px)
|
|
||||||
|
|
||||||
.dropdown-toggle
|
|
||||||
padding-right: 0
|
|
||||||
|
|
||||||
i.icon-dropdown-menu
|
|
||||||
padding-left: 10px
|
|
||||||
|
|
||||||
/* Download dropdown options */
|
/* Download dropdown options */
|
||||||
ul.dropdown-menu
|
ul.dropdown-menu
|
||||||
width: auto
|
|
||||||
min-width: 240px
|
min-width: 240px
|
||||||
padding: 0
|
|
||||||
margin: 0
|
|
||||||
top: initial
|
|
||||||
left: initial
|
|
||||||
right: 0
|
|
||||||
bottom: 35px
|
|
||||||
border: thin solid rgba(darken($color-background, 5%), .3)
|
|
||||||
border-top-left-radius: 3px
|
|
||||||
border-top-right-radius: 3px
|
|
||||||
border-bottom-left-radius: 0
|
|
||||||
border-bottom-right-radius: 0
|
|
||||||
|
|
||||||
li
|
li
|
||||||
padding: 0
|
display: block
|
||||||
text-align: right
|
|
||||||
clear: both
|
|
||||||
display: flex
|
|
||||||
align-items: center
|
|
||||||
|
|
||||||
a
|
a
|
||||||
|
display: flex
|
||||||
padding: 10px 15px
|
padding: 10px 15px
|
||||||
width: 100%
|
|
||||||
|
|
||||||
&:hover
|
&:hover
|
||||||
color: $color-primary
|
text-decoration: none
|
||||||
background-color: transparent
|
|
||||||
|
|
||||||
span
|
span
|
||||||
|
color: $primary
|
||||||
|
|
||||||
&.length
|
&.length
|
||||||
color: lighten($color-primary, 10%)
|
color: lighten($color-primary, 10%)
|
||||||
|
|
||||||
span
|
span
|
||||||
|
color: $body-color
|
||||||
|
|
||||||
&.length
|
&.length
|
||||||
float: left
|
margin-right: auto
|
||||||
color: $color-text-dark-hint
|
color: $color-text-dark-hint
|
||||||
padding-right: 15px
|
padding-right: 15px
|
||||||
&.format
|
&.format
|
||||||
@@ -998,34 +735,38 @@ section.node-preview.group
|
|||||||
&.status
|
&.status
|
||||||
color: $color-text-dark
|
color: $color-text-dark
|
||||||
|
|
||||||
.node-details-license
|
ul.blender-id-badges
|
||||||
align-items: center
|
padding-left: 0
|
||||||
border-bottom: thin solid $color-background
|
list-style-type: none
|
||||||
color: $color-text-dark
|
|
||||||
display: flex
|
display: flex
|
||||||
font-weight: lighter
|
|
||||||
padding: 10px 20px
|
|
||||||
|
|
||||||
|
> li
|
||||||
|
margin: 0
|
||||||
|
padding: 0
|
||||||
|
|
||||||
|
> a
|
||||||
|
display: block
|
||||||
|
padding: 2px
|
||||||
|
|
||||||
|
img
|
||||||
|
// TODO(Pablo): fix this so that it keeps the aspect ratio okay when the original image isn't square.
|
||||||
|
max-width: 16px
|
||||||
|
max-height: 16px
|
||||||
|
|
||||||
|
.node-details-license
|
||||||
|
color: $color-text-dark-secondary
|
||||||
&:hover
|
&:hover
|
||||||
color: $color-text-dark
|
color: $primary
|
||||||
|
|
||||||
span.type
|
|
||||||
i
|
i
|
||||||
color: $color-text-dark-primary
|
font-size: 1.2rem
|
||||||
font-size: 1.8rem
|
margin-left: -15px
|
||||||
margin-left: -5px
|
|
||||||
padding-right: 10px
|
|
||||||
|
|
||||||
&:after
|
&:after
|
||||||
top: 2px
|
|
||||||
left: -22px
|
left: -22px
|
||||||
position: relative
|
position: relative
|
||||||
&:before
|
top: 1px
|
||||||
top: 2px
|
|
||||||
position: relative
|
|
||||||
|
|
||||||
span
|
|
||||||
text-transform: uppercase
|
|
||||||
|
|
||||||
section.node-details-container
|
section.node-details-container
|
||||||
&.storage
|
&.storage
|
||||||
@@ -1196,9 +937,6 @@ a.learn-more
|
|||||||
font-size: 1.6em
|
font-size: 1.6em
|
||||||
left: 7px
|
left: 7px
|
||||||
|
|
||||||
.ribbon
|
|
||||||
+ribbon
|
|
||||||
|
|
||||||
.info
|
.info
|
||||||
width: 100%
|
width: 100%
|
||||||
height: 100%
|
height: 100%
|
||||||
@@ -1233,12 +971,6 @@ a.learn-more
|
|||||||
text-transform: capitalize
|
text-transform: capitalize
|
||||||
|
|
||||||
|
|
||||||
section.node-children
|
|
||||||
&.group, &.storage
|
|
||||||
flex: 1
|
|
||||||
padding: 10px 0 25px 20px
|
|
||||||
+clearfix
|
|
||||||
|
|
||||||
.list-node-children-container
|
.list-node-children-container
|
||||||
position: relative
|
position: relative
|
||||||
width: $list-node-children-item-width
|
width: $list-node-children-item-width
|
||||||
@@ -1825,9 +1557,6 @@ section.node-children
|
|||||||
padding: 20px
|
padding: 20px
|
||||||
|
|
||||||
.form-group
|
.form-group
|
||||||
position: relative
|
|
||||||
margin: 0 auto 30px auto
|
|
||||||
|
|
||||||
&.tags .select2-container
|
&.tags .select2-container
|
||||||
.select2-selection
|
.select2-selection
|
||||||
+input-generic
|
+input-generic
|
||||||
@@ -2046,7 +1775,7 @@ section.node-children
|
|||||||
box-shadow: 0 5px 35px rgba(black, .2)
|
box-shadow: 0 5px 35px rgba(black, .2)
|
||||||
color: $color-text-dark-primary
|
color: $color-text-dark-primary
|
||||||
position: absolute
|
position: absolute
|
||||||
top: 0
|
top: -$project_header-height
|
||||||
left: 0
|
left: 0
|
||||||
right: 0
|
right: 0
|
||||||
width: 80%
|
width: 80%
|
||||||
@@ -2074,7 +1803,7 @@ section.node-children
|
|||||||
&.visible
|
&.visible
|
||||||
visibility: visible
|
visibility: visible
|
||||||
opacity: 1
|
opacity: 1
|
||||||
top: $project_header-height
|
top: 0
|
||||||
|
|
||||||
.overlay-container
|
.overlay-container
|
||||||
.title
|
.title
|
||||||
@@ -2291,3 +2020,6 @@ section.node-children
|
|||||||
margin: 25px 0 0 0
|
margin: 25px 0 0 0
|
||||||
padding: 5px 35px
|
padding: 5px 35px
|
||||||
text-align: center
|
text-align: center
|
||||||
|
|
||||||
|
.ribbon
|
||||||
|
+ribbon
|
||||||
|
@@ -16,7 +16,7 @@ $search-hit-width_grid: 100px
|
|||||||
.search-hit-name
|
.search-hit-name
|
||||||
font-weight: 400
|
font-weight: 400
|
||||||
padding-top: 8px
|
padding-top: 8px
|
||||||
color: $color-primary-dark
|
color: $primary
|
||||||
|
|
||||||
.search-hit
|
.search-hit
|
||||||
padding: 0
|
padding: 0
|
||||||
@@ -35,7 +35,7 @@ $search-hit-width_grid: 100px
|
|||||||
+clearfix
|
+clearfix
|
||||||
|
|
||||||
& em
|
& em
|
||||||
color: $color-primary-dark
|
color: $primary
|
||||||
font-style: normal
|
font-style: normal
|
||||||
|
|
||||||
&:hover
|
&:hover
|
||||||
@@ -70,7 +70,7 @@ $search-hit-width_grid: 100px
|
|||||||
min-width: 350px
|
min-width: 350px
|
||||||
border-bottom-left-radius: 3px
|
border-bottom-left-radius: 3px
|
||||||
border-bottom-right-radius: 3px
|
border-bottom-right-radius: 3px
|
||||||
border-top: 3px solid lighten($color-primary, 5%)
|
border-top: 3px solid lighten($primary, 5%)
|
||||||
overflow: hidden
|
overflow: hidden
|
||||||
|
|
||||||
.tt-suggestion
|
.tt-suggestion
|
||||||
@@ -92,222 +92,37 @@ $search-hit-width_grid: 100px
|
|||||||
&.tt-cursor:hover .search-hit
|
&.tt-cursor:hover .search-hit
|
||||||
background-color: lighten($color-background, 5%)
|
background-color: lighten($color-background, 5%)
|
||||||
|
|
||||||
#search-container
|
.search-list
|
||||||
display: flex
|
width: 30%
|
||||||
min-height: 600px
|
|
||||||
background-color: white
|
|
||||||
|
|
||||||
+media-lg
|
.card-deck.card-deck-vertical
|
||||||
padding-left: 0
|
.card .embed-responsive
|
||||||
padding-right: 0
|
max-width: 80px
|
||||||
|
|
||||||
#search-sidebar
|
|
||||||
width: 20%
|
|
||||||
background-color: $color-background-light
|
|
||||||
|
|
||||||
+media-lg
|
|
||||||
border-top-left-radius: 3px
|
|
||||||
|
|
||||||
input.search-field
|
input.search-field
|
||||||
background-color: $color-background-nav-dark
|
|
||||||
font-size: 1.1em
|
|
||||||
color: white
|
|
||||||
margin-bottom: 10px
|
|
||||||
border: none
|
border: none
|
||||||
border-bottom: 2px solid rgba($color-primary, .2)
|
border-bottom: 2px solid rgba($primary, .2)
|
||||||
border-radius: 0
|
border-radius: 0
|
||||||
width: 100%
|
width: 100%
|
||||||
padding: 5px 15px
|
|
||||||
height: 50px
|
|
||||||
transition: border 100ms ease-in-out
|
transition: border 100ms ease-in-out
|
||||||
|
|
||||||
&::placeholder
|
&::placeholder
|
||||||
color: $color-text-dark-secondary
|
color: $color-text-dark-secondary
|
||||||
&:placeholder-shown
|
&:placeholder-shown
|
||||||
border-bottom-color: $color-primary
|
border-bottom-color: $primary
|
||||||
|
|
||||||
&:focus
|
&:focus
|
||||||
outline: none
|
outline: none
|
||||||
border: none
|
border: none
|
||||||
border-bottom: 2px solid lighten($color-primary, 5%)
|
border-bottom: 2px solid lighten($primary, 5%)
|
||||||
|
|
||||||
.search-list-filters
|
.search-details
|
||||||
padding:
|
width: 70%
|
||||||
left: 10px
|
|
||||||
right: 10px
|
|
||||||
|
|
||||||
.panel.panel-default
|
|
||||||
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
|
|
||||||
overflow-y: auto
|
|
||||||
|
|
||||||
#hits
|
|
||||||
position: relative
|
|
||||||
width: 100%
|
|
||||||
|
|
||||||
#no-hits
|
|
||||||
padding: 10px 15px
|
|
||||||
color: $color-text-dark-secondary
|
|
||||||
|
|
||||||
.search-hit
|
|
||||||
#search-loading
|
|
||||||
visibility: hidden
|
|
||||||
background-color: transparent
|
|
||||||
font:
|
|
||||||
size: 1.5em
|
|
||||||
weight: 600
|
|
||||||
position: absolute
|
|
||||||
top: 0
|
|
||||||
left: 0
|
|
||||||
right: 0
|
|
||||||
bottom: 0
|
|
||||||
z-index: $z-index-base + 5
|
|
||||||
opacity: 0
|
|
||||||
cursor: default
|
|
||||||
transition: opacity 50ms ease-in-out
|
|
||||||
&.active
|
|
||||||
visibility: visible
|
|
||||||
opacity: 1
|
|
||||||
|
|
||||||
.spinner
|
|
||||||
color: $color-background-nav
|
|
||||||
background-color: white
|
|
||||||
padding: 0
|
|
||||||
width: 20px
|
|
||||||
height: 20px
|
|
||||||
border-radius: 50%
|
|
||||||
position: absolute
|
|
||||||
top: 7px
|
|
||||||
right: 10px
|
|
||||||
span
|
|
||||||
padding: 5px
|
|
||||||
+pulse
|
|
||||||
|
|
||||||
#search-details
|
#search-details
|
||||||
position: relative
|
position: relative
|
||||||
width: 40%
|
|
||||||
border-left: 2px solid darken(white, 3%)
|
|
||||||
|
|
||||||
#search-hit-container
|
#search-hit-container
|
||||||
position: absolute // for scrollbars
|
position: absolute // for scrollbars
|
||||||
width: 100%
|
|
||||||
overflow-y: auto
|
overflow-y: auto
|
||||||
|
|
||||||
#error_container
|
#error_container
|
||||||
@@ -321,6 +136,7 @@ $search-hit-width_grid: 100px
|
|||||||
color: $color-danger
|
color: $color-danger
|
||||||
text-align: center
|
text-align: center
|
||||||
|
|
||||||
|
#search-container
|
||||||
#node-container
|
#node-container
|
||||||
width: 100%
|
width: 100%
|
||||||
max-width: 100%
|
max-width: 100%
|
||||||
@@ -473,215 +289,118 @@ $search-hit-width_grid: 100px
|
|||||||
button
|
button
|
||||||
width: 100%
|
width: 100%
|
||||||
|
|
||||||
.search-hit
|
#project_sidebar+#search-sidebar,
|
||||||
float: left
|
#project_sidebar+#search-sidebar+#search-container
|
||||||
box-shadow: none
|
padding-left: $sidebar-width
|
||||||
border: thin solid transparent
|
|
||||||
border-top-color: darken(white, 8%)
|
|
||||||
border-left: 3px solid transparent
|
|
||||||
|
|
||||||
color: $color-background-nav
|
.search-project
|
||||||
|
li.project
|
||||||
|
display: none
|
||||||
|
|
||||||
width: 100%
|
#search-sidebar
|
||||||
|
.card
|
||||||
|
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: $primary
|
||||||
|
|
||||||
|
&.refined
|
||||||
|
color: $primary
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
color: $color-danger
|
||||||
|
|
||||||
|
span
|
||||||
|
&:before
|
||||||
|
/* x icon */
|
||||||
|
content: '\e84b'
|
||||||
|
font-family: 'pillar-font'
|
||||||
|
span
|
||||||
|
&:before
|
||||||
|
/* circle with dot */
|
||||||
|
content: '\e82f'
|
||||||
|
font-family: 'pillar-font'
|
||||||
position: relative
|
position: relative
|
||||||
margin: 0
|
left: -7px
|
||||||
padding: 7px 10px 7px 10px
|
font-size: .9em
|
||||||
|
|
||||||
|
span
|
||||||
|
&:before
|
||||||
|
/* empty circle */
|
||||||
|
content: '\e82c'
|
||||||
|
font-family: 'pillar-font'
|
||||||
|
position: relative
|
||||||
|
left: -7px
|
||||||
|
font-size: .9em
|
||||||
|
.facet_count
|
||||||
|
color: $color-text-dark-secondary
|
||||||
|
|
||||||
|
.card-title
|
||||||
|
position: relative
|
||||||
|
&:after
|
||||||
|
content: '\e83b'
|
||||||
|
font-family: 'pillar-font'
|
||||||
|
position: absolute
|
||||||
|
right: 0
|
||||||
|
color: $color-text-dark-primary
|
||||||
|
|
||||||
|
.collapsed
|
||||||
|
.card-title:after
|
||||||
|
content: '\e838'
|
||||||
|
|
||||||
|
|
||||||
|
.search-list-stats
|
||||||
|
color: $color-text-dark-hint
|
||||||
|
padding: 10px 15px 0 15px
|
||||||
|
text-align: center
|
||||||
|
font-size: .9em
|
||||||
+clearfix
|
+clearfix
|
||||||
|
|
||||||
&:first-child
|
.search-pagination
|
||||||
border: thin solid transparent
|
|
||||||
border-left: 3px solid transparent
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
opacity: 1
|
|
||||||
text-decoration: none
|
|
||||||
cursor: default
|
|
||||||
color: darken($color-primary, 20%)
|
|
||||||
background-color: $color-background-light
|
|
||||||
|
|
||||||
& .search-hit-name i
|
|
||||||
color: darken($color-primary, 20%)
|
|
||||||
|
|
||||||
& .search-hit-thumbnail
|
|
||||||
& .search-hit-thumbnail-icon
|
|
||||||
transform: translate(-50%, -50%) scale(1.1)
|
|
||||||
|
|
||||||
.search-hit-name
|
|
||||||
text-decoration: none
|
|
||||||
&:hover
|
|
||||||
color: darken($color-primary, 10%)
|
|
||||||
|
|
||||||
.search-hit-thumbnail
|
|
||||||
cursor: pointer
|
|
||||||
|
|
||||||
.search-hit-thumbnail-icon
|
|
||||||
transform: translate(-50%, -50%) scale(1)
|
|
||||||
|
|
||||||
&:active
|
|
||||||
background-color: rgba($color-background, .5)
|
|
||||||
opacity: .8
|
|
||||||
color: $color-primary
|
|
||||||
& .search-hit-name i
|
|
||||||
color: $color-primary
|
|
||||||
|
|
||||||
&:focus
|
|
||||||
border-color: rgba($color-primary, .2)
|
|
||||||
|
|
||||||
/* Class that gets added when we click on the item */
|
|
||||||
&.active
|
|
||||||
background-color: lighten($color-background, 2%)
|
|
||||||
border-left: 3px solid $color-primary
|
|
||||||
|
|
||||||
.search-hit-name
|
|
||||||
color: darken($color-primary, 10%)
|
|
||||||
|
|
||||||
.search-hit-meta
|
|
||||||
span.when
|
|
||||||
display: none
|
|
||||||
span.context
|
|
||||||
display: inline-block
|
|
||||||
|
|
||||||
.search-hit-thumbnail
|
|
||||||
position: relative
|
|
||||||
float: left
|
|
||||||
min-width: $search-hit-width_list * 1.49
|
|
||||||
max-width: $search-hit-width_list * 1.49
|
|
||||||
height: $search-hit-width_list
|
|
||||||
border-radius: 3px
|
|
||||||
background: $color-background
|
|
||||||
margin-right: 12px
|
|
||||||
text-align: center
|
text-align: center
|
||||||
overflow: hidden
|
list-style-type: none
|
||||||
+media-xs
|
|
||||||
display: none
|
|
||||||
+media-sm
|
|
||||||
min-width: $search-hit-width_list
|
|
||||||
max-width: $search-hit-width_list
|
|
||||||
|
|
||||||
img
|
|
||||||
height: $search-hit-width_list
|
|
||||||
width: auto
|
|
||||||
|
|
||||||
|
|
||||||
.pi-video:before, .pi-file:before,
|
|
||||||
.pi-group:before
|
|
||||||
font-family: 'pillar-font'
|
|
||||||
.pi-video:before
|
|
||||||
content: '\e81d'
|
|
||||||
.pi-file:before
|
|
||||||
content: '\e825'
|
|
||||||
.pi-group:before
|
|
||||||
content: '\e80d'
|
|
||||||
|
|
||||||
.search-hit-thumbnail-icon
|
|
||||||
position: absolute
|
|
||||||
top: 50%
|
|
||||||
left: 50%
|
|
||||||
transform: translate(-50%, -50%)
|
|
||||||
color: white
|
|
||||||
font-size: 1.2em
|
|
||||||
transition: none
|
|
||||||
color: $color-text-dark-secondary
|
|
||||||
|
|
||||||
.dark
|
|
||||||
text-shadow: none
|
|
||||||
font-size: 1.3em
|
|
||||||
|
|
||||||
.search-hit-name
|
|
||||||
position: relative
|
|
||||||
font-size: 1.1em
|
|
||||||
color: $color-text-dark-primary
|
|
||||||
background-color: initial
|
|
||||||
width: initial
|
|
||||||
max-width: initial
|
|
||||||
+text-overflow-ellipsis
|
|
||||||
padding-top: 5px
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
cursor: pointer
|
|
||||||
text-decoration: underline
|
|
||||||
|
|
||||||
em
|
|
||||||
color: darken($color-primary, 15%)
|
|
||||||
font-style: normal
|
|
||||||
|
|
||||||
.search-hit-ribbon
|
|
||||||
+ribbon
|
|
||||||
right: -30px
|
|
||||||
top: 5px
|
|
||||||
|
|
||||||
span
|
|
||||||
font-size: 60%
|
|
||||||
margin: 1px 0
|
|
||||||
padding: 2px 35px
|
|
||||||
|
|
||||||
.search-hit-meta
|
|
||||||
position: relative
|
|
||||||
font-size: .9em
|
|
||||||
color: $color-text-dark-secondary
|
|
||||||
background-color: initial
|
|
||||||
padding: 3px 0 0 0
|
|
||||||
text-decoration: none
|
|
||||||
+text-overflow-ellipsis
|
|
||||||
|
|
||||||
span
|
|
||||||
&.project
|
|
||||||
color: $color-text-dark-secondary
|
|
||||||
margin-right: 3px
|
|
||||||
&.updated
|
|
||||||
color: $color-text-dark-hint
|
|
||||||
&.status
|
|
||||||
font-size: .8em
|
|
||||||
color: $color-text-dark-secondary
|
|
||||||
border: thin solid $color-text-dark-hint
|
|
||||||
padding: 3px 8px
|
|
||||||
text-transform: uppercase
|
|
||||||
border-radius: 3px
|
|
||||||
margin-right: 5px
|
|
||||||
&.media, &.node_type
|
|
||||||
color: $color-text-dark-secondary
|
|
||||||
text-transform: capitalize
|
|
||||||
margin: 0 3px
|
|
||||||
|
|
||||||
&.when
|
|
||||||
margin: 0 3px
|
|
||||||
float: right
|
|
||||||
display: block
|
|
||||||
+media-lg
|
|
||||||
display: block
|
|
||||||
+media-md
|
|
||||||
display: block
|
|
||||||
+media-sm
|
|
||||||
display: none
|
|
||||||
+media-xs
|
|
||||||
display: none
|
|
||||||
|
|
||||||
&.context
|
|
||||||
margin: 0
|
margin: 0
|
||||||
float: right
|
padding: 0
|
||||||
display: none
|
width: 100%
|
||||||
|
display: flex
|
||||||
|
+clearfix
|
||||||
|
|
||||||
|
li
|
||||||
|
display: inline-block
|
||||||
|
margin: 5px auto
|
||||||
|
|
||||||
|
&:last-child
|
||||||
|
border-color: transparent
|
||||||
|
|
||||||
|
a
|
||||||
|
font-weight: 500
|
||||||
|
padding: 5px 4px
|
||||||
|
color: $color-text-dark-secondary
|
||||||
|
|
||||||
&:hover
|
&:hover
|
||||||
cursor: pointer
|
color: $color-text-dark-primary
|
||||||
.search-hit-name-user
|
|
||||||
color: $color-primary
|
|
||||||
|
|
||||||
&.users
|
&.disabled
|
||||||
em
|
opacity: .6
|
||||||
font-style: normal
|
|
||||||
color: $color-primary
|
|
||||||
|
|
||||||
.search-hit-name
|
&.active a
|
||||||
font-size: 1.2em
|
color: $color-text-dark-primary
|
||||||
|
font-weight: bold
|
||||||
|
|
||||||
small
|
|
||||||
margin-left: 5px
|
|
||||||
color: $color-text-dark-secondary
|
|
||||||
|
|
||||||
.search-hit-roles
|
|
||||||
font-size: .9em
|
|
||||||
color: $color-text-dark-secondary
|
|
||||||
margin-left: 15px
|
|
||||||
|
|
||||||
.view-grid
|
.view-grid
|
||||||
display: flex
|
display: flex
|
||||||
@@ -703,13 +422,13 @@ $search-hit-width_grid: 100px
|
|||||||
transition: border-color 150ms ease-in-out
|
transition: border-color 150ms ease-in-out
|
||||||
|
|
||||||
&.active
|
&.active
|
||||||
background-color: $color-primary
|
background-color: $primary
|
||||||
border-color: $color-primary
|
border-color: $primary
|
||||||
|
|
||||||
.search-hit-name
|
.search-hit-name
|
||||||
font-weight: 500
|
font-weight: 500
|
||||||
color: white
|
color: white
|
||||||
background-color: $color-primary
|
background-color: $primary
|
||||||
|
|
||||||
.search-hit-name
|
.search-hit-name
|
||||||
font-size: .9em
|
font-size: .9em
|
||||||
@@ -773,5 +492,5 @@ $search-hit-width_grid: 100px
|
|||||||
|
|
||||||
&.active
|
&.active
|
||||||
color: white
|
color: white
|
||||||
background-color: $color-primary
|
background-color: $primary
|
||||||
border-color: transparent
|
border-color: transparent
|
||||||
|
@@ -67,131 +67,6 @@
|
|||||||
&:hover
|
&:hover
|
||||||
background-color: lighten($provider-color-google, 7%)
|
background-color: lighten($provider-color-google, 7%)
|
||||||
|
|
||||||
#settings
|
|
||||||
#settings-sidebar
|
|
||||||
+media-xs
|
|
||||||
width: 100%
|
|
||||||
|
|
||||||
+container-box
|
|
||||||
background-color: $color-background-light
|
|
||||||
color: $color-text
|
|
||||||
margin-right: 15px
|
|
||||||
width: 30%
|
|
||||||
|
|
||||||
.settings-content
|
|
||||||
padding: 0
|
|
||||||
|
|
||||||
ul
|
|
||||||
list-style: none
|
|
||||||
margin: 0
|
|
||||||
padding: 0
|
|
||||||
|
|
||||||
a
|
|
||||||
&:hover
|
|
||||||
text-decoration: none
|
|
||||||
|
|
||||||
li
|
|
||||||
background-color: lighten($color-background, 5%)
|
|
||||||
|
|
||||||
li
|
|
||||||
border-bottom: thin solid $color-background
|
|
||||||
border-left: thick solid transparent
|
|
||||||
margin: 0
|
|
||||||
padding: 25px
|
|
||||||
transition: all 100ms ease-in-out
|
|
||||||
|
|
||||||
i
|
|
||||||
font-size: 1.1em
|
|
||||||
padding-right: 15px
|
|
||||||
|
|
||||||
.active
|
|
||||||
li
|
|
||||||
background-color: lighten($color-background, 5%)
|
|
||||||
border-left: thick solid $color-info
|
|
||||||
|
|
||||||
|
|
||||||
#settings-container
|
|
||||||
+media-xs
|
|
||||||
width: 100%
|
|
||||||
|
|
||||||
+container-box
|
|
||||||
background-color: $color-background-light
|
|
||||||
width: 70%
|
|
||||||
|
|
||||||
.settings-header
|
|
||||||
background-color: $color-background
|
|
||||||
border-top-left-radius: 3px
|
|
||||||
border-top-right-radius: 3px
|
|
||||||
|
|
||||||
.settings-title
|
|
||||||
font:
|
|
||||||
size: 1.5em
|
|
||||||
weight: 300
|
|
||||||
padding: 10px 15px 10px 25px
|
|
||||||
|
|
||||||
.settings-content
|
|
||||||
padding: 25px
|
|
||||||
|
|
||||||
.settings-billing-info
|
|
||||||
font-size: 1.2em
|
|
||||||
|
|
||||||
.subscription-active
|
|
||||||
color: $color-success
|
|
||||||
padding-bottom: 20px
|
|
||||||
.subscription-demo
|
|
||||||
color: $color-info
|
|
||||||
margin-top: 0
|
|
||||||
.subscription-missing
|
|
||||||
color: $color-danger
|
|
||||||
margin-top: 0
|
|
||||||
|
|
||||||
.button-submit
|
|
||||||
clear: both
|
|
||||||
display: block
|
|
||||||
min-width: 200px
|
|
||||||
margin: 0 auto
|
|
||||||
+button($color-primary, 3px, true)
|
|
||||||
|
|
||||||
|
|
||||||
#settings-container
|
|
||||||
#settings-form
|
|
||||||
width: 100%
|
|
||||||
|
|
||||||
|
|
||||||
.settings-form
|
|
||||||
align-items: center
|
|
||||||
display: flex
|
|
||||||
justify-content: center
|
|
||||||
|
|
||||||
.left, .right
|
|
||||||
padding: 25px 0
|
|
||||||
|
|
||||||
.left
|
|
||||||
width: 60%
|
|
||||||
float: left
|
|
||||||
|
|
||||||
.right
|
|
||||||
width: 40%
|
|
||||||
float: right
|
|
||||||
text-align: center
|
|
||||||
|
|
||||||
label
|
|
||||||
color: $color-text
|
|
||||||
display: block
|
|
||||||
|
|
||||||
.settings-avatar
|
|
||||||
img
|
|
||||||
border-radius: 3px
|
|
||||||
|
|
||||||
span
|
|
||||||
display: block
|
|
||||||
padding: 15px 0
|
|
||||||
font:
|
|
||||||
size: .9em
|
|
||||||
|
|
||||||
.settings-password
|
|
||||||
color: $color-text-dark-primary
|
|
||||||
|
|
||||||
|
|
||||||
#user-edit-container
|
#user-edit-container
|
||||||
padding: 15px
|
padding: 15px
|
||||||
|
@@ -171,17 +171,25 @@
|
|||||||
/* Small but wide: phablets, iPads
|
/* Small but wide: phablets, iPads
|
||||||
** Menu is collapsed, columns stack, no brand */
|
** Menu is collapsed, columns stack, no brand */
|
||||||
=media-sm
|
=media-sm
|
||||||
@media (min-width: #{$screen-tablet}) and (max-width: #{$screen-desktop - 1px})
|
@include media-breakpoint-up(sm)
|
||||||
@content
|
@content
|
||||||
|
|
||||||
/* Tablets portrait.
|
/* Tablets portrait.
|
||||||
** Menu is expanded, but columns stack, brand is shown */
|
** Menu is expanded, but columns stack, brand is shown */
|
||||||
=media-md
|
=media-md
|
||||||
@media (min-width: #{$screen-desktop})
|
@include media-breakpoint-up(md)
|
||||||
@content
|
@content
|
||||||
|
|
||||||
=media-lg
|
=media-lg
|
||||||
@media (min-width: #{$screen-lg-desktop})
|
@include media-breakpoint-up(lg)
|
||||||
|
@content
|
||||||
|
|
||||||
|
=media-xl
|
||||||
|
@include media-breakpoint-up(xl)
|
||||||
|
@content
|
||||||
|
|
||||||
|
=media-xxl
|
||||||
|
@include media-breakpoint-up(xxl)
|
||||||
@content
|
@content
|
||||||
|
|
||||||
=media-print
|
=media-print
|
||||||
@@ -507,28 +515,22 @@
|
|||||||
|
|
||||||
=ribbon
|
=ribbon
|
||||||
background-color: $color-success
|
background-color: $color-success
|
||||||
cursor: default
|
border: thin dashed rgba(white, .5)
|
||||||
|
color: white
|
||||||
|
pointer-events: none
|
||||||
|
font-size: 70%
|
||||||
overflow: hidden
|
overflow: hidden
|
||||||
white-space: nowrap
|
|
||||||
position: absolute
|
position: absolute
|
||||||
right: -40px
|
right: -40px
|
||||||
top: 10px
|
top: 10px
|
||||||
-webkit-transform: rotate(45deg)
|
|
||||||
-moz-transform: rotate(45deg)
|
|
||||||
-ms-transform: rotate(45deg)
|
|
||||||
-o-transform: rotate(45deg)
|
|
||||||
transform: rotate(45deg)
|
transform: rotate(45deg)
|
||||||
|
white-space: nowrap
|
||||||
|
|
||||||
span
|
span
|
||||||
border: thin dashed rgba(white, .5)
|
|
||||||
color: white
|
|
||||||
display: block
|
display: block
|
||||||
font-size: 70%
|
|
||||||
margin: 1px 0
|
margin: 1px 0
|
||||||
padding: 3px 50px
|
padding: 3px 50px
|
||||||
text:
|
|
||||||
align: center
|
|
||||||
transform: uppercase
|
|
||||||
|
|
||||||
@mixin text-background($text-color, $background-color, $roundness, $padding)
|
@mixin text-background($text-color, $background-color, $roundness, $padding)
|
||||||
border-radius: $roundness
|
border-radius: $roundness
|
||||||
@@ -642,9 +644,7 @@
|
|||||||
#{$property}: $color-status-review
|
#{$property}: $color-status-review
|
||||||
|
|
||||||
=sidebar-button-active
|
=sidebar-button-active
|
||||||
background-color: $color-background-nav
|
color: $primary
|
||||||
box-shadow: inset 2px 0 0 $color-primary
|
|
||||||
color: white
|
|
||||||
|
|
||||||
.flash-on
|
.flash-on
|
||||||
background-color: lighten($color-success, 50%) !important
|
background-color: lighten($color-success, 50%) !important
|
||||||
@@ -667,6 +667,9 @@
|
|||||||
.user-select-none
|
.user-select-none
|
||||||
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%.
|
// 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
|
// .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.
|
// classes to the images themselves. e.g. the blog.
|
||||||
@@ -674,3 +677,18 @@
|
|||||||
img
|
img
|
||||||
// Just re-use Bootstrap's mixin here.
|
// Just re-use Bootstrap's mixin here.
|
||||||
+img-fluid
|
+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)
|
||||||
|
@@ -15,93 +15,44 @@
|
|||||||
|
|
||||||
@import "../../node_modules/bootstrap/scss/code"
|
@import "../../node_modules/bootstrap/scss/code"
|
||||||
@import "../../node_modules/bootstrap/scss/grid"
|
@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/buttons"
|
||||||
@import "../../node_modules/bootstrap/scss/transitions"
|
|
||||||
@import "../../node_modules/bootstrap/scss/dropdown"
|
@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/custom-forms"
|
||||||
|
|
||||||
@import "../../node_modules/bootstrap/scss/nav"
|
@import "../../node_modules/bootstrap/scss/nav"
|
||||||
@import "../../node_modules/bootstrap/scss/navbar"
|
@import "../../node_modules/bootstrap/scss/navbar"
|
||||||
|
|
||||||
@import "../../node_modules/bootstrap/scss/card"
|
@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/jumbotron"
|
||||||
@import "../../node_modules/bootstrap/scss/alert"
|
|
||||||
@import "../../node_modules/bootstrap/scss/progress"
|
|
||||||
@import "../../node_modules/bootstrap/scss/media"
|
@import "../../node_modules/bootstrap/scss/media"
|
||||||
@import "../../node_modules/bootstrap/scss/list-group"
|
|
||||||
@import "../../node_modules/bootstrap/scss/close"
|
@import "../../node_modules/bootstrap/scss/close"
|
||||||
@import "../../node_modules/bootstrap/scss/modal"
|
@import "../../node_modules/bootstrap/scss/modal"
|
||||||
@import "../../node_modules/bootstrap/scss/tooltip"
|
@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/utilities"
|
||||||
@import "../../node_modules/bootstrap/scss/print"
|
|
||||||
|
|
||||||
// Pillar components.
|
// Pillar components.
|
||||||
@import "apps_base"
|
@import "apps_base"
|
||||||
@import "components/base"
|
@import "components/base"
|
||||||
|
|
||||||
|
@import "components/card"
|
||||||
@import "components/jumbotron"
|
@import "components/jumbotron"
|
||||||
@import "components/alerts"
|
|
||||||
@import "components/navbar"
|
@import "components/navbar"
|
||||||
@import "components/dropdown"
|
@import "components/dropdown"
|
||||||
@import "components/footer"
|
@import "components/footer"
|
||||||
@import "components/shortcode"
|
@import "components/shortcode"
|
||||||
@import "components/statusbar"
|
|
||||||
@import "components/search"
|
|
||||||
@import "components/flyout"
|
@import "components/flyout"
|
||||||
@import "components/forms"
|
|
||||||
@import "components/inputs"
|
|
||||||
@import "components/buttons"
|
@import "components/buttons"
|
||||||
@import "components/popover"
|
|
||||||
@import "components/tooltip"
|
@import "components/tooltip"
|
||||||
@import "components/checkbox"
|
|
||||||
@import "components/overlay"
|
@import "components/overlay"
|
||||||
@import "components/card"
|
|
||||||
|
|
||||||
@import _comments
|
@import _comments
|
||||||
@import _error
|
@import _notifications
|
||||||
@import _search
|
|
||||||
|
|
||||||
@import components/base
|
|
||||||
@import components/alerts
|
|
||||||
@import components/navbar
|
|
||||||
@import components/footer
|
|
||||||
@import components/shortcode
|
|
||||||
@import components/statusbar
|
|
||||||
@import components/search
|
|
||||||
@import components/flyout
|
|
||||||
@import components/forms
|
|
||||||
@import components/inputs
|
|
||||||
@import components/buttons
|
|
||||||
@import components/popover
|
|
||||||
@import components/tooltip
|
|
||||||
@import components/checkbox
|
|
||||||
@import components/overlay
|
|
||||||
|
|
||||||
#blog_container
|
|
||||||
+media-xs
|
|
||||||
flex-direction: column
|
|
||||||
padding-top: 0
|
|
||||||
|
|
||||||
video
|
|
||||||
max-width: 100%
|
|
||||||
|
|
||||||
#blog_post-edit-form
|
#blog_post-edit-form
|
||||||
padding: 20px
|
padding: 20px
|
||||||
|
|
||||||
.form-group
|
.form-group
|
||||||
position: relative
|
|
||||||
margin: 0 auto 30px auto
|
|
||||||
|
|
||||||
input, textarea, select
|
input, textarea, select
|
||||||
+input-generic
|
+input-generic
|
||||||
|
|
||||||
@@ -169,7 +120,6 @@
|
|||||||
margin-bottom: 15px
|
margin-bottom: 15px
|
||||||
border-top: thin solid $color-text-dark-hint
|
border-top: thin solid $color-text-dark-hint
|
||||||
|
|
||||||
|
|
||||||
.form-group.description,
|
.form-group.description,
|
||||||
.form-group.summary,
|
.form-group.summary,
|
||||||
.form-group.content
|
.form-group.content
|
||||||
@@ -237,64 +187,10 @@
|
|||||||
color: transparent
|
color: transparent
|
||||||
|
|
||||||
|
|
||||||
#blog_post-create-container,
|
|
||||||
#blog_post-edit-container
|
|
||||||
padding: 25px
|
|
||||||
|
|
||||||
.blog_index-item
|
|
||||||
.item-picture
|
|
||||||
position: relative
|
|
||||||
width: 100%
|
|
||||||
max-height: 350px
|
|
||||||
min-height: 200px
|
|
||||||
height: auto
|
|
||||||
overflow: hidden
|
|
||||||
border-top-left-radius: 3px
|
|
||||||
border-top-right-radius: 3px
|
|
||||||
+clearfix
|
|
||||||
|
|
||||||
img
|
|
||||||
+position-center-translate
|
|
||||||
width: 100%
|
|
||||||
border-top-left-radius: 3px
|
|
||||||
border-top-right-radius: 3px
|
|
||||||
|
|
||||||
+media-xs
|
|
||||||
min-height: 150px
|
|
||||||
+media-sm
|
|
||||||
min-height: 150px
|
|
||||||
+media-md
|
|
||||||
min-height: 250px
|
|
||||||
+media-lg
|
|
||||||
min-height: 250px
|
|
||||||
|
|
||||||
.item-content
|
|
||||||
+node-details-description
|
|
||||||
|
|
||||||
+media-xs
|
|
||||||
padding:
|
|
||||||
left: 0
|
|
||||||
right: 0
|
|
||||||
|
|
||||||
img
|
|
||||||
display: block
|
|
||||||
margin: 0 auto
|
|
||||||
|
|
||||||
.item-meta
|
|
||||||
color: $color-text-dark-secondary
|
|
||||||
padding:
|
|
||||||
left: 25px
|
|
||||||
right: 25px
|
|
||||||
|
|
||||||
+media-xs
|
|
||||||
padding:
|
|
||||||
left: 10px
|
|
||||||
right: 10px
|
|
||||||
|
|
||||||
#blog_index-container,
|
|
||||||
#blog_post-create-container,
|
#blog_post-create-container,
|
||||||
#blog_post-edit-container
|
#blog_post-edit-container
|
||||||
+container-box
|
+container-box
|
||||||
|
padding: 25px
|
||||||
width: 75%
|
width: 75%
|
||||||
|
|
||||||
+media-xs
|
+media-xs
|
||||||
@@ -309,11 +205,6 @@
|
|||||||
+media-lg
|
+media-lg
|
||||||
width: 100%
|
width: 100%
|
||||||
|
|
||||||
|
|
||||||
.item-picture+.button-back+.button-edit
|
|
||||||
right: 20px
|
|
||||||
top: 20px
|
|
||||||
|
|
||||||
#blog_post-edit-form
|
#blog_post-edit-form
|
||||||
padding: 0
|
padding: 0
|
||||||
|
|
||||||
@@ -348,206 +239,3 @@
|
|||||||
|
|
||||||
.form-upload-file-meta
|
.form-upload-file-meta
|
||||||
width: initial
|
width: initial
|
||||||
|
|
||||||
#blog_post-edit-title
|
|
||||||
padding: 0
|
|
||||||
color: $color-text
|
|
||||||
font:
|
|
||||||
size: 1.8em
|
|
||||||
weight: 300
|
|
||||||
margin: 0 20px 15px 0
|
|
||||||
|
|
||||||
#blog_index-sidebar
|
|
||||||
width: 25%
|
|
||||||
padding: 0 15px
|
|
||||||
|
|
||||||
+media-xs
|
|
||||||
width: 100%
|
|
||||||
clear: both
|
|
||||||
display: block
|
|
||||||
margin-top: 25px
|
|
||||||
+media-sm
|
|
||||||
width: 40%
|
|
||||||
+media-md
|
|
||||||
width: 30%
|
|
||||||
+media-lg
|
|
||||||
width: 25%
|
|
||||||
|
|
||||||
.button-back
|
|
||||||
+button($color-info, 6px, true)
|
|
||||||
display: block
|
|
||||||
width: 100%
|
|
||||||
margin: 15px 0 0 0
|
|
||||||
|
|
||||||
#blog_post-edit-form
|
|
||||||
.form-group
|
|
||||||
.form-control
|
|
||||||
background-color: white
|
|
||||||
|
|
||||||
.blog_index-sidebar,
|
|
||||||
.blog_project-sidebar
|
|
||||||
+container-box
|
|
||||||
background-color: lighten($color-background, 5%)
|
|
||||||
padding: 20px
|
|
||||||
|
|
||||||
.blog_project-card
|
|
||||||
position: relative
|
|
||||||
width: 100%
|
|
||||||
border-radius: 3px
|
|
||||||
overflow: hidden
|
|
||||||
background-color: white
|
|
||||||
color: lighten($color-text, 10%)
|
|
||||||
box-shadow: 0 0 30px rgba(black, .2)
|
|
||||||
|
|
||||||
margin:
|
|
||||||
top: 0
|
|
||||||
bottom: 15px
|
|
||||||
left: auto
|
|
||||||
right: auto
|
|
||||||
|
|
||||||
|
|
||||||
a.item-header
|
|
||||||
position: relative
|
|
||||||
width: 100%
|
|
||||||
height: 100px
|
|
||||||
display: block
|
|
||||||
background-size: 100% 100%
|
|
||||||
|
|
||||||
overflow: hidden
|
|
||||||
|
|
||||||
.overlay
|
|
||||||
z-index: 1
|
|
||||||
width: 100%
|
|
||||||
height: 100px
|
|
||||||
@include overlay(transparent, 0%, white, 100%)
|
|
||||||
|
|
||||||
|
|
||||||
img.background
|
|
||||||
width: 100%
|
|
||||||
transform: scale(1.4)
|
|
||||||
|
|
||||||
.card-thumbnail
|
|
||||||
position: absolute
|
|
||||||
z-index: 2
|
|
||||||
height: 90px
|
|
||||||
width: 90px
|
|
||||||
display: block
|
|
||||||
top: 35px
|
|
||||||
left: 50%
|
|
||||||
transform: translateX(-50%)
|
|
||||||
background-color: white
|
|
||||||
border-radius: 3px
|
|
||||||
overflow: hidden
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
img.thumb
|
|
||||||
opacity: .9
|
|
||||||
|
|
||||||
img.thumb
|
|
||||||
width: 100%
|
|
||||||
border-radius: 3px
|
|
||||||
transition: opacity 150ms ease-in-out
|
|
||||||
+position-center-translate
|
|
||||||
|
|
||||||
.item-info
|
|
||||||
padding: 10px 20px
|
|
||||||
background-color: white
|
|
||||||
border-bottom-left-radius: 3px
|
|
||||||
border-bottom-right-radius: 3px
|
|
||||||
|
|
||||||
a.item-title
|
|
||||||
display: inline-block
|
|
||||||
width: 100%
|
|
||||||
padding: 30px 0 15px 0
|
|
||||||
color: $color-text-dark
|
|
||||||
text-align: center
|
|
||||||
font:
|
|
||||||
size: 1.6em
|
|
||||||
weight: 300
|
|
||||||
|
|
||||||
transition: color 150ms ease-in-out
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
text-decoration: none
|
|
||||||
color: $color-primary
|
|
||||||
|
|
||||||
.blog-archive-navigation
|
|
||||||
+media-xs
|
|
||||||
font-size: 1em
|
|
||||||
max-width: initial
|
|
||||||
|
|
||||||
border-bottom: thin solid $color-background-dark
|
|
||||||
display: flex
|
|
||||||
font:
|
|
||||||
size: 1.2em
|
|
||||||
weight: 300
|
|
||||||
margin: 0 auto
|
|
||||||
max-width: 780px
|
|
||||||
text-align: center
|
|
||||||
+text-overflow-ellipsis
|
|
||||||
|
|
||||||
&:last-child
|
|
||||||
border: none
|
|
||||||
|
|
||||||
a, span
|
|
||||||
+media-xs
|
|
||||||
padding: 10px
|
|
||||||
|
|
||||||
flex: 1
|
|
||||||
padding: 25px 15px
|
|
||||||
|
|
||||||
span
|
|
||||||
color: $color-text-dark-secondary
|
|
||||||
pointer-events: none
|
|
||||||
|
|
||||||
// Specific tweaks for blogs in the context of a project.
|
|
||||||
#project_context
|
|
||||||
.blog_index-item
|
|
||||||
+media-xs
|
|
||||||
margin-left: 0
|
|
||||||
padding: 0
|
|
||||||
margin-left: 10px
|
|
||||||
|
|
||||||
&.list
|
|
||||||
margin-left: 35px !important
|
|
||||||
|
|
||||||
.item-title,
|
|
||||||
.item-info
|
|
||||||
+media-xs
|
|
||||||
padding-left: 0
|
|
||||||
padding-left: 25px
|
|
||||||
|
|
||||||
#blog_container
|
|
||||||
.comments-container
|
|
||||||
+media-sm
|
|
||||||
margin-left: 10px
|
|
||||||
margin-left: 30px
|
|
||||||
|
|
||||||
.blog-archive-navigation
|
|
||||||
margin-left: 35px
|
|
||||||
|
|
||||||
// Used on the blog.
|
|
||||||
.comments-compact
|
|
||||||
.comments-list
|
|
||||||
border: none
|
|
||||||
padding: 0 0 15px 0
|
|
||||||
|
|
||||||
.comments-container
|
|
||||||
max-width: 680px
|
|
||||||
margin: 0 auto
|
|
||||||
|
|
||||||
.comment-reply-container
|
|
||||||
background-color: transparent
|
|
||||||
|
|
||||||
.comment-reply-field
|
|
||||||
textarea, .comment-reply-meta
|
|
||||||
background-color: $color-background-light
|
|
||||||
|
|
||||||
&.filled
|
|
||||||
.comment-reply-meta
|
|
||||||
background-color: $color-success
|
|
||||||
|
|
||||||
.comment-reply-form
|
|
||||||
+media-xs
|
|
||||||
padding:
|
|
||||||
left: 0
|
|
||||||
|
@@ -1,23 +1,132 @@
|
|||||||
.card-deck
|
.card-deck
|
||||||
// Custom, as of bootstrap 4.1.3 there is no way to do this.
|
// Custom, as of bootstrap 4.1.3 there is no way to do this.
|
||||||
&.card-3-columns
|
&.card-deck-responsive
|
||||||
.card
|
@extend .row
|
||||||
min-width: 30%
|
|
||||||
max-width: 30%
|
|
||||||
|
|
||||||
|
.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-xl
|
||||||
|
flex: 1 0 33%
|
||||||
|
max-width: 33%
|
||||||
|
|
||||||
|
+media-xxl
|
||||||
|
flex: 1 0 33%
|
||||||
|
max-width: 33%
|
||||||
|
|
||||||
|
&.card-deck-vertical
|
||||||
|
@extend .flex-column
|
||||||
|
flex-wrap: initial
|
||||||
|
|
||||||
|
.card
|
||||||
|
@extend .w-100
|
||||||
|
@extend .flex-row
|
||||||
|
flex: initial
|
||||||
|
flex-wrap: wrap
|
||||||
|
max-width: 100%
|
||||||
|
|
||||||
|
.card-img-top
|
||||||
|
@extend .rounded-0
|
||||||
|
|
||||||
|
.embed-responsive
|
||||||
|
@extend .mr-2
|
||||||
|
max-width: 120px
|
||||||
|
|
||||||
|
.card-body
|
||||||
|
@extend .overflow-hidden
|
||||||
|
|
||||||
.card-padless
|
.card-padless
|
||||||
.card
|
.card
|
||||||
border: none
|
@extend .border-0
|
||||||
|
|
||||||
.card-body
|
.card-body
|
||||||
padding: 15px 0
|
@extend .px-0
|
||||||
|
|
||||||
.card-fade
|
|
||||||
img
|
.card-image-fade
|
||||||
opacity: .8
|
&:hover
|
||||||
transition: opacity ease-in-out 150ms
|
.card-img-top
|
||||||
|
opacity: .9
|
||||||
|
|
||||||
|
|
||||||
|
.card.asset
|
||||||
|
color: $color-text
|
||||||
|
|
||||||
&:hover
|
&:hover
|
||||||
img
|
text-decoration: none
|
||||||
opacity: 1
|
|
||||||
|
&.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
|
||||||
|
@@ -1,13 +1,44 @@
|
|||||||
// Global, we want all menus to look like this.
|
// Global, we want all menus to look like this.
|
||||||
.dropdown-menu
|
ul.dropdown-menu
|
||||||
box-shadow: $dropdown-box-shadow
|
box-shadow: $dropdown-box-shadow
|
||||||
top: 95% // So there is less gap between the dropdown and the item.
|
top: 95% // So there is less gap between the dropdown and the item.
|
||||||
|
|
||||||
> li
|
> li
|
||||||
|
&:first-child > a
|
||||||
|
padding-top: ($dropdown-item-padding-y * 1.5)
|
||||||
|
&:last-child > a
|
||||||
|
padding-bottom: ($dropdown-item-padding-y * 1.5)
|
||||||
|
|
||||||
> a
|
> a
|
||||||
padding: $dropdown-item-padding-y
|
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.
|
// Open dropdown on mouse hover dropdowns in the navbar.
|
||||||
nav .dropdown:hover
|
nav .dropdown:hover
|
||||||
ul.dropdown-menu
|
ul.dropdown-menu
|
||||||
display: block
|
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
|
||||||
|
@@ -20,6 +20,6 @@
|
|||||||
overflow-x: hidden
|
overflow-x: hidden
|
||||||
position: absolute
|
position: absolute
|
||||||
right: 0
|
right: 0
|
||||||
top: 60px
|
top: 40px
|
||||||
width: 420px
|
width: 420px
|
||||||
z-index: 9999
|
z-index: 9999
|
||||||
|
@@ -1,25 +1,41 @@
|
|||||||
// Mainly overrides bootstrap jumbotron settings
|
// Mainly overrides bootstrap jumbotron settings
|
||||||
.jumbotron
|
.jumbotron
|
||||||
|
@extend .d-flex
|
||||||
|
@extend .mb-0
|
||||||
|
@extend .rounded-0
|
||||||
background-size: cover
|
background-size: cover
|
||||||
border-radius: 0
|
margin-bottom: 0
|
||||||
padding-top: 10em
|
padding-top: 10em
|
||||||
padding-bottom: 10em
|
padding-bottom: 10em
|
||||||
|
|
||||||
// Black-transparent gradient from left to right to better read the overlay text.
|
|
||||||
&.jumbotron-overlay
|
|
||||||
position: relative
|
position: relative
|
||||||
|
|
||||||
&:after
|
&:after
|
||||||
background-image: linear-gradient(45deg, rgba(black, .5) 25%, transparent 50%)
|
background-color: rgba(black, .5)
|
||||||
bottom: 0
|
bottom: 0
|
||||||
content: ''
|
content: ''
|
||||||
|
display: none
|
||||||
left: 0
|
left: 0
|
||||||
position: absolute
|
position: absolute
|
||||||
right: 0
|
right: 0
|
||||||
top: 0
|
top: 0
|
||||||
|
visibility: hidden
|
||||||
|
|
||||||
|
// Black-transparent gradient from left to right to better read the overlay text.
|
||||||
|
&.jumbotron-overlay
|
||||||
*
|
*
|
||||||
z-index: 1
|
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
|
h2, p
|
||||||
text-shadow: 1px 1px rgba(black, .2), 1px 1px 25px rgba(black, .5)
|
text-shadow: 1px 1px rgba(black, .2), 1px 1px 25px rgba(black, .5)
|
||||||
|
@@ -1,14 +1,12 @@
|
|||||||
// Navigation.
|
/* Top level navigation bar. */
|
||||||
|
|
||||||
.navbar
|
.navbar
|
||||||
box-shadow: inset 0 -2px $color-background
|
box-shadow: inset 0 -2px $color-background
|
||||||
|
|
||||||
.navbar,
|
.nav
|
||||||
nav.sidebar
|
|
||||||
border: none
|
border: none
|
||||||
color: $color-text-dark-secondary
|
color: $color-text-dark-secondary
|
||||||
padding: 0
|
padding: 0
|
||||||
z-index: $z-index-base + 5 /* Flowplayer seems to take up to 11, project container is 12 */
|
z-index: $z-index-base + 5
|
||||||
|
|
||||||
nav
|
nav
|
||||||
margin-left: auto
|
margin-left: auto
|
||||||
@@ -20,6 +18,88 @@ nav.sidebar
|
|||||||
margin: 0
|
margin: 0
|
||||||
width: 100%
|
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
|
.navbar-item
|
||||||
align-items: center
|
align-items: center
|
||||||
display: flex
|
display: flex
|
||||||
@@ -43,104 +123,79 @@ nav.sidebar
|
|||||||
color: $primary
|
color: $primary
|
||||||
box-shadow: inset 0 -3px 0 $primary
|
box-shadow: inset 0 -3px 0 $primary
|
||||||
|
|
||||||
li
|
|
||||||
user-select: none
|
|
||||||
position: relative
|
|
||||||
|
|
||||||
img.gravatar
|
/* Secondary navigation. */
|
||||||
border-radius: 999em
|
$nav-secondary-bar-size: -2px
|
||||||
height: 32px
|
|
||||||
width: 32px
|
|
||||||
box-shadow: 1px 1px 0 rgba(black, .2)
|
|
||||||
position: relative
|
|
||||||
|
|
||||||
.special
|
|
||||||
width: 18px
|
|
||||||
height: 18px
|
|
||||||
border-radius: 999em
|
|
||||||
position: absolute
|
|
||||||
background-color: white
|
|
||||||
z-index: 2
|
|
||||||
display: inline-block
|
|
||||||
top: 10px
|
|
||||||
left: 38px
|
|
||||||
font-size: 1.2em
|
|
||||||
box-shadow: 1px 1px 1px rgba(black, .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
|
|
||||||
min-width: 60px // navbar avatar size
|
|
||||||
|
|
||||||
span.fa-stack
|
|
||||||
position: absolute
|
|
||||||
top: 50%
|
|
||||||
left: 50%
|
|
||||||
transform: translate(-50%, -50%)
|
|
||||||
|
|
||||||
ul.dropdown-menu
|
|
||||||
li
|
|
||||||
a
|
|
||||||
white-space: nowrap
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
box-shadow: none // removes underline
|
|
||||||
|
|
||||||
&.subitem // e.g. "Not Sintel? Log out"
|
|
||||||
font-size: .8em
|
|
||||||
padding-top: 0
|
|
||||||
text-transform: initial
|
|
||||||
|
|
||||||
i
|
|
||||||
width: 30px
|
|
||||||
|
|
||||||
&.subscription-status
|
|
||||||
&.none a
|
|
||||||
color: $color-danger
|
|
||||||
|
|
||||||
&.subscriber a
|
|
||||||
color: $color-success
|
|
||||||
|
|
||||||
&.demo a
|
|
||||||
color: $color-info
|
|
||||||
|
|
||||||
span.info
|
|
||||||
display: block
|
|
||||||
|
|
||||||
span.renew
|
|
||||||
display: block
|
|
||||||
color: $color-text-dark-primary
|
|
||||||
font-size: .9em
|
|
||||||
|
|
||||||
|
|
||||||
// Secondary navigation for
|
|
||||||
.nav-secondary
|
.nav-secondary
|
||||||
align-items: center
|
align-items: center
|
||||||
box-shadow: inset 0 -2px 0 0 $color-background
|
box-shadow: inset 0 $nav-secondary-bar-size 0 0 $color-background
|
||||||
|
|
||||||
.nav-link
|
.nav-link
|
||||||
color: $color-text
|
color: $color-text
|
||||||
cursor: pointer
|
cursor: pointer
|
||||||
transition: box-shadow 150ms ease-in-out
|
transition: color 150ms ease-in-out
|
||||||
|
|
||||||
|
&:after
|
||||||
|
background-color: transparent
|
||||||
|
bottom: 0
|
||||||
|
content: ''
|
||||||
|
height: 2px
|
||||||
|
position: absolute
|
||||||
|
right: 0
|
||||||
|
left: 0
|
||||||
|
width: 0
|
||||||
|
transition: width 150ms ease-in-out
|
||||||
|
|
||||||
|
.nav-link:hover,
|
||||||
|
.nav-link.active,
|
||||||
|
.nav-item.dropdown.show > .nav-link
|
||||||
|
// Blue bar on the bottom.
|
||||||
|
&:after
|
||||||
|
background-color: $primary-accent
|
||||||
|
background-image: linear-gradient(to right, $primary-accent 70%, $primary)
|
||||||
|
height: 2px
|
||||||
|
width: 100%
|
||||||
|
|
||||||
|
span
|
||||||
|
+active-gradient
|
||||||
|
|
||||||
|
i
|
||||||
|
color: $primary-accent
|
||||||
|
|
||||||
|
&.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,
|
&:hover,
|
||||||
&.active
|
&.active
|
||||||
box-shadow: inset 0 -2px 0 0 $primary
|
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
|
||||||
|
.nav-secondary
|
||||||
|
.nav-link
|
||||||
|
@extend .pr-5
|
||||||
|
box-shadow: none
|
||||||
|
|
||||||
|
&.nav-see-more
|
||||||
|
color: $primary
|
||||||
|
|
||||||
|
i, span
|
||||||
|
+active-gradient
|
||||||
|
|
||||||
.navbar-overlay
|
.navbar-overlay
|
||||||
+media-lg
|
+media-lg
|
||||||
@@ -160,13 +215,6 @@ nav.sidebar
|
|||||||
background-color: $color-background-nav
|
background-color: $color-background-nav
|
||||||
text-shadow: none
|
text-shadow: none
|
||||||
|
|
||||||
.navbar-brand
|
|
||||||
color: inherit
|
|
||||||
padding-left: 4px
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
color: $primary
|
|
||||||
|
|
||||||
nav.navbar
|
nav.navbar
|
||||||
.navbar-collapse
|
.navbar-collapse
|
||||||
> ul > li > .navbar-item
|
> ul > li > .navbar-item
|
||||||
|
@@ -3,10 +3,14 @@
|
|||||||
/* Makes it possible to override the path before importing font-pillar.sass */
|
/* Makes it possible to override the path before importing font-pillar.sass */
|
||||||
$pillar-font-path: "../font" !default
|
$pillar-font-path: "../font" !default
|
||||||
|
|
||||||
/* Font aliases */
|
/* Font properties. */
|
||||||
.pi /* blank item with the right spacing */
|
@font-face
|
||||||
&:after
|
font-family: 'pillar-font'
|
||||||
content: ''
|
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
|
||||||
|
|
||||||
|
[class^="pi-"]:before, [class*=" pi-"]:before
|
||||||
font-family: "pillar-font"
|
font-family: "pillar-font"
|
||||||
font-style: normal
|
font-style: normal
|
||||||
font-weight: normal
|
font-weight: normal
|
||||||
@@ -22,15 +26,55 @@ $pillar-font-path: "../font" !default
|
|||||||
margin-left: .2em
|
margin-left: .2em
|
||||||
-webkit-font-smoothing: antialiased
|
-webkit-font-smoothing: antialiased
|
||||||
-moz-osx-font-smoothing: grayscale
|
-moz-osx-font-smoothing: grayscale
|
||||||
|
|
||||||
|
/* 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
|
position: relative
|
||||||
|
|
||||||
&:before
|
&:before
|
||||||
position: relative
|
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
|
.pi-license-cc-zero:before
|
||||||
content: '\e85a'
|
content: '\e85a'
|
||||||
.pi-license-cc-sa:before
|
.pi-license-cc-sa:before
|
||||||
content: '\e858'
|
content: '\e858'
|
||||||
|
top: 1px
|
||||||
.pi-license-cc-nd:before
|
.pi-license-cc-nd:before
|
||||||
content: '\e859'
|
content: '\e859'
|
||||||
.pi-license-cc-nc:before
|
.pi-license-cc-nc:before
|
||||||
@@ -59,47 +103,17 @@ $pillar-font-path: "../font" !default
|
|||||||
&:before
|
&:before
|
||||||
left: 27px
|
left: 27px
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||||
* Here begins the CSS code generated by fontello.com, converted to *
|
* Here begins the CSS code generated by fontello.com by using *
|
||||||
* Sass and replaced the path with our variable $pillar-font-path. *
|
* 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 *
|
||||||
|
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@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")
|
|
||||||
font-weight: normal
|
|
||||||
font-style: normal
|
|
||||||
|
|
||||||
[class^="pi-"]:before, [class*=" pi-"]:before
|
|
||||||
font-family: "pillar-font"
|
|
||||||
font-style: normal
|
|
||||||
font-weight: normal
|
|
||||||
speak: none
|
|
||||||
display: inline-block
|
|
||||||
text-decoration: inherit
|
|
||||||
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);
|
|
||||||
|
|
||||||
.pi-collection-plus:before
|
.pi-collection-plus:before
|
||||||
content: '\e800'
|
content: '\e800'
|
||||||
|
|
||||||
@@ -495,6 +509,11 @@ $pillar-font-path: "../font" !default
|
|||||||
|
|
||||||
/* ''
|
/* ''
|
||||||
|
|
||||||
|
.pi-speed:before
|
||||||
|
content: '\e84f'
|
||||||
|
|
||||||
|
/* ''
|
||||||
|
|
||||||
.pi-attention:before
|
.pi-attention:before
|
||||||
content: '\e850'
|
content: '\e850'
|
||||||
|
|
||||||
@@ -645,11 +664,6 @@ $pillar-font-path: "../font" !default
|
|||||||
|
|
||||||
/* ''
|
/* ''
|
||||||
|
|
||||||
.pi-users:before
|
|
||||||
content: '\e86e'
|
|
||||||
|
|
||||||
/* ''
|
|
||||||
|
|
||||||
.pi-flamenco:before
|
.pi-flamenco:before
|
||||||
content: '\e86f'
|
content: '\e86f'
|
||||||
|
|
||||||
@@ -670,6 +684,11 @@ $pillar-font-path: "../font" !default
|
|||||||
|
|
||||||
/* ''
|
/* ''
|
||||||
|
|
||||||
|
.pi-users:before
|
||||||
|
content: '\e873'
|
||||||
|
|
||||||
|
/* ''
|
||||||
|
|
||||||
.pi-pause:before
|
.pi-pause:before
|
||||||
content: '\f00e'
|
content: '\f00e'
|
||||||
|
|
||||||
@@ -705,6 +724,16 @@ $pillar-font-path: "../font" !default
|
|||||||
|
|
||||||
/* ''
|
/* ''
|
||||||
|
|
||||||
|
.pi-social-instagram:before
|
||||||
|
content: '\f16d'
|
||||||
|
|
||||||
|
/* ''
|
||||||
|
|
||||||
|
.pi-database:before
|
||||||
|
content: '\f1c0'
|
||||||
|
|
||||||
|
/* ''
|
||||||
|
|
||||||
.pi-newspaper:before
|
.pi-newspaper:before
|
||||||
content: '\f1ea'
|
content: '\f1ea'
|
||||||
|
|
||||||
|
@@ -33,11 +33,10 @@ $tree-color-highlight-background-text: $primary
|
|||||||
|
|
||||||
&[data-node-type="page"],
|
&[data-node-type="page"],
|
||||||
&[data-node-type="blog"]
|
&[data-node-type="blog"]
|
||||||
color: darken($tree-color-highlight, 5%)
|
|
||||||
font-weight: bold
|
font-weight: bold
|
||||||
|
|
||||||
.jstree-anchor
|
.jstree-anchor
|
||||||
padding: 5px 8px 1px 8px
|
padding: 0 6px
|
||||||
|
|
||||||
&:after
|
&:after
|
||||||
top: 3px !important
|
top: 3px !important
|
||||||
|
@@ -1,5 +1,7 @@
|
|||||||
$videoplayer-controls-color: white
|
$videoplayer-controls-color: white
|
||||||
$videoplayer-background-color: $black
|
$videoplayer-background-color: darken($primary, 10%)
|
||||||
|
|
||||||
|
$videoplayer-progress-bar-height: .5em
|
||||||
|
|
||||||
.video-js
|
.video-js
|
||||||
.vjs-big-play-button:before, .vjs-control:before, .vjs-modal-dialog
|
.vjs-big-play-button:before, .vjs-control:before, .vjs-modal-dialog
|
||||||
@@ -30,7 +32,6 @@ $videoplayer-background-color: $black
|
|||||||
font-weight: normal
|
font-weight: normal
|
||||||
font-style: normal
|
font-style: normal
|
||||||
|
|
||||||
|
|
||||||
.vjs-icon-play
|
.vjs-icon-play
|
||||||
font-family: VideoJS
|
font-family: VideoJS
|
||||||
font-weight: normal
|
font-weight: normal
|
||||||
@@ -285,7 +286,6 @@ $videoplayer-background-color: $black
|
|||||||
line-height: 1
|
line-height: 1
|
||||||
font-weight: normal
|
font-weight: normal
|
||||||
font-style: normal
|
font-style: normal
|
||||||
font-family: Arial, Helvetica, sans-serif
|
|
||||||
-webkit-user-select: none
|
-webkit-user-select: none
|
||||||
-moz-user-select: none
|
-moz-user-select: none
|
||||||
-ms-user-select: none
|
-ms-user-select: none
|
||||||
@@ -453,20 +453,22 @@ body.vjs-full-window
|
|||||||
list-style: none
|
list-style: none
|
||||||
margin: 0
|
margin: 0
|
||||||
padding: 0.2em 0
|
padding: 0.2em 0
|
||||||
line-height: 1.4em
|
line-height: 1.8em
|
||||||
font-size: 1.2em
|
font-size: 1.1em
|
||||||
text-align: center
|
text-align: center
|
||||||
text-transform: lowercase
|
text-transform: lowercase
|
||||||
|
|
||||||
&:focus, &:hover
|
&:focus, &:hover
|
||||||
outline: 0
|
background-color: darken($primary, 20%)
|
||||||
background-color: #73859f
|
|
||||||
background-color: rgba(115, 133, 159, 0.5)
|
|
||||||
&.vjs-selected
|
&.vjs-selected
|
||||||
background-color: $videoplayer-controls-color
|
background-color: $videoplayer-controls-color
|
||||||
color: $videoplayer-background-color
|
color: $videoplayer-background-color
|
||||||
|
|
||||||
&:focus, &:hover
|
&:focus, &:hover
|
||||||
background-color: $videoplayer-controls-color
|
background-color: $videoplayer-controls-color
|
||||||
color: $videoplayer-background-color
|
color: $videoplayer-background-color
|
||||||
|
|
||||||
&.vjs-menu-title
|
&.vjs-menu-title
|
||||||
text-align: center
|
text-align: center
|
||||||
text-transform: uppercase
|
text-transform: uppercase
|
||||||
@@ -486,12 +488,13 @@ body.vjs-full-window
|
|||||||
height: 0em
|
height: 0em
|
||||||
margin-bottom: 1.5em
|
margin-bottom: 1.5em
|
||||||
border-top-color: $videoplayer-background-color
|
border-top-color: $videoplayer-background-color
|
||||||
|
|
||||||
.vjs-menu-content
|
.vjs-menu-content
|
||||||
background-color: $videoplayer-background-color
|
background-color: $videoplayer-background-color
|
||||||
position: absolute
|
position: absolute
|
||||||
width: 100%
|
width: 100%
|
||||||
bottom: 1.5em
|
bottom: 1.5em
|
||||||
max-height: 15em
|
max-height: 25em
|
||||||
|
|
||||||
.vjs-workinghover .vjs-menu-button-popup:hover .vjs-menu, .vjs-menu-button-popup .vjs-menu.vjs-lock-showing
|
.vjs-workinghover .vjs-menu-button-popup:hover .vjs-menu, .vjs-menu-button-popup .vjs-menu.vjs-lock-showing
|
||||||
display: block
|
display: block
|
||||||
@@ -655,12 +658,12 @@ body.vjs-full-window
|
|||||||
-moz-transition: all 0.2s
|
-moz-transition: all 0.2s
|
||||||
-o-transition: all 0.2s
|
-o-transition: all 0.2s
|
||||||
transition: all 0.2s
|
transition: all 0.2s
|
||||||
height: 0.3em
|
height: $videoplayer-progress-bar-height
|
||||||
|
|
||||||
.vjs-play-progress
|
.vjs-play-progress
|
||||||
position: absolute
|
position: absolute
|
||||||
display: block
|
display: block
|
||||||
height: 0.3em
|
height: $videoplayer-progress-bar-height
|
||||||
margin: 0
|
margin: 0
|
||||||
padding: 0
|
padding: 0
|
||||||
width: 0
|
width: 0
|
||||||
@@ -670,7 +673,7 @@ body.vjs-full-window
|
|||||||
.vjs-load-progress
|
.vjs-load-progress
|
||||||
position: absolute
|
position: absolute
|
||||||
display: block
|
display: block
|
||||||
height: 0.3em
|
height: $videoplayer-progress-bar-height
|
||||||
margin: 0
|
margin: 0
|
||||||
padding: 0
|
padding: 0
|
||||||
width: 0
|
width: 0
|
||||||
@@ -680,7 +683,7 @@ body.vjs-full-window
|
|||||||
div
|
div
|
||||||
position: absolute
|
position: absolute
|
||||||
display: block
|
display: block
|
||||||
height: 0.3em
|
height: $videoplayer-progress-bar-height
|
||||||
margin: 0
|
margin: 0
|
||||||
padding: 0
|
padding: 0
|
||||||
width: 0
|
width: 0
|
||||||
@@ -692,10 +695,11 @@ body.vjs-full-window
|
|||||||
|
|
||||||
.vjs-play-progress
|
.vjs-play-progress
|
||||||
background-color: $videoplayer-controls-color
|
background-color: $videoplayer-controls-color
|
||||||
|
border-radius: 999em
|
||||||
|
|
||||||
&:before
|
&:before
|
||||||
position: absolute
|
position: absolute
|
||||||
top: -0.333333333333333em
|
top: -($videoplayer-progress-bar-height / 2) // halfway the height of the progress bar
|
||||||
right: -0.5em
|
right: -0.5em
|
||||||
|
|
||||||
&:after
|
&:after
|
||||||
@@ -712,8 +716,8 @@ body.vjs-full-window
|
|||||||
z-index: 1
|
z-index: 1
|
||||||
|
|
||||||
.vjs-time-tooltip
|
.vjs-time-tooltip
|
||||||
background-color: $videoplayer-background-color
|
|
||||||
color: $videoplayer-controls-color
|
color: $videoplayer-controls-color
|
||||||
|
background-color: $videoplayer-background-color
|
||||||
z-index: 1
|
z-index: 1
|
||||||
|
|
||||||
&:after
|
&:after
|
||||||
@@ -735,9 +739,9 @@ body.vjs-full-window
|
|||||||
|
|
||||||
.vjs-time-tooltip
|
.vjs-time-tooltip
|
||||||
background-color: $videoplayer-controls-color
|
background-color: $videoplayer-controls-color
|
||||||
border-radius: 3px
|
border-radius: $border-radius
|
||||||
color: $videoplayer-background-color
|
color: $videoplayer-background-color
|
||||||
font-family: $font-body
|
font-family: $font-family-base
|
||||||
font-size: 1.2em
|
font-size: 1.2em
|
||||||
font-weight: bold
|
font-weight: bold
|
||||||
padding: 5px 8px
|
padding: 5px 8px
|
||||||
@@ -851,9 +855,9 @@ body.vjs-full-window
|
|||||||
font-size: 0.9em
|
font-size: 0.9em
|
||||||
|
|
||||||
.vjs-slider-horizontal .vjs-volume-level
|
.vjs-slider-horizontal .vjs-volume-level
|
||||||
height: 0.3em
|
height: $videoplayer-progress-bar-height
|
||||||
&:before
|
&:before
|
||||||
top: -0.3em
|
top: -$videoplayer-progress-bar-height
|
||||||
right: -0.5em
|
right: -0.5em
|
||||||
|
|
||||||
.vjs-menu-button-popup
|
.vjs-menu-button-popup
|
||||||
@@ -1022,14 +1026,15 @@ video::-webkit-media-text-track-display
|
|||||||
|
|
||||||
.vjs-playback-rate
|
.vjs-playback-rate
|
||||||
.vjs-playback-rate-value
|
.vjs-playback-rate-value
|
||||||
font-size: 1.5em
|
font-size: 1.25em
|
||||||
line-height: 2
|
line-height: 2
|
||||||
position: absolute
|
position: absolute
|
||||||
top: 0
|
top: 3px
|
||||||
left: 0
|
left: 0
|
||||||
width: 100%
|
width: 100%
|
||||||
height: 100%
|
height: 100%
|
||||||
text-align: center
|
text-align: center
|
||||||
|
|
||||||
.vjs-menu
|
.vjs-menu
|
||||||
width: 4em
|
width: 4em
|
||||||
left: 0em
|
left: 0em
|
||||||
@@ -1041,7 +1046,6 @@ video::-webkit-media-text-track-display
|
|||||||
&:before
|
&:before
|
||||||
color: $videoplayer-controls-color
|
color: $videoplayer-controls-color
|
||||||
content: 'X'
|
content: 'X'
|
||||||
font-family: Arial, Helvetica, sans-serif
|
|
||||||
font-size: 4em
|
font-size: 4em
|
||||||
left: 0
|
left: 0
|
||||||
line-height: 1
|
line-height: 1
|
||||||
|
@@ -1,82 +0,0 @@
|
|||||||
// Bootstrap variables and utilities.
|
|
||||||
@import "../../node_modules/bootstrap/scss/functions"
|
|
||||||
@import "../../node_modules/bootstrap/scss/variables"
|
|
||||||
@import "../../node_modules/bootstrap/scss/mixins"
|
|
||||||
|
|
||||||
@import _config
|
|
||||||
@import _utils
|
|
||||||
|
|
||||||
// Bootstrap components.
|
|
||||||
@import "../../node_modules/bootstrap/scss/root"
|
|
||||||
@import "../../node_modules/bootstrap/scss/reboot"
|
|
||||||
|
|
||||||
@import "../../node_modules/bootstrap/scss/type"
|
|
||||||
@import "../../node_modules/bootstrap/scss/images"
|
|
||||||
|
|
||||||
@import "../../node_modules/bootstrap/scss/code"
|
|
||||||
@import "../../node_modules/bootstrap/scss/grid"
|
|
||||||
@import "../../node_modules/bootstrap/scss/tables"
|
|
||||||
@import "../../node_modules/bootstrap/scss/forms"
|
|
||||||
@import "../../node_modules/bootstrap/scss/buttons"
|
|
||||||
@import "../../node_modules/bootstrap/scss/transitions"
|
|
||||||
@import "../../node_modules/bootstrap/scss/dropdown"
|
|
||||||
@import "../../node_modules/bootstrap/scss/button-group"
|
|
||||||
@import "../../node_modules/bootstrap/scss/input-group"
|
|
||||||
@import "../../node_modules/bootstrap/scss/custom-forms"
|
|
||||||
|
|
||||||
@import "../../node_modules/bootstrap/scss/nav"
|
|
||||||
@import "../../node_modules/bootstrap/scss/navbar"
|
|
||||||
|
|
||||||
@import "../../node_modules/bootstrap/scss/card"
|
|
||||||
@import "../../node_modules/bootstrap/scss/breadcrumb"
|
|
||||||
@import "../../node_modules/bootstrap/scss/pagination"
|
|
||||||
@import "../../node_modules/bootstrap/scss/badge"
|
|
||||||
@import "../../node_modules/bootstrap/scss/jumbotron"
|
|
||||||
@import "../../node_modules/bootstrap/scss/alert"
|
|
||||||
@import "../../node_modules/bootstrap/scss/progress"
|
|
||||||
@import "../../node_modules/bootstrap/scss/media"
|
|
||||||
@import "../../node_modules/bootstrap/scss/list-group"
|
|
||||||
@import "../../node_modules/bootstrap/scss/close"
|
|
||||||
@import "../../node_modules/bootstrap/scss/modal"
|
|
||||||
@import "../../node_modules/bootstrap/scss/tooltip"
|
|
||||||
@import "../../node_modules/bootstrap/scss/popover"
|
|
||||||
@import "../../node_modules/bootstrap/scss/carousel"
|
|
||||||
|
|
||||||
@import "../../node_modules/bootstrap/scss/utilities"
|
|
||||||
@import "../../node_modules/bootstrap/scss/print"
|
|
||||||
|
|
||||||
// Pillar components.
|
|
||||||
@import "apps_base"
|
|
||||||
@import "components/base"
|
|
||||||
|
|
||||||
@import "components/jumbotron"
|
|
||||||
@import "components/alerts"
|
|
||||||
@import "components/navbar"
|
|
||||||
@import "components/dropdown"
|
|
||||||
@import "components/footer"
|
|
||||||
@import "components/shortcode"
|
|
||||||
@import "components/statusbar"
|
|
||||||
@import "components/search"
|
|
||||||
|
|
||||||
@import "components/flyout"
|
|
||||||
@import "components/forms"
|
|
||||||
@import "components/inputs"
|
|
||||||
@import "components/buttons"
|
|
||||||
@import "components/popover"
|
|
||||||
@import "components/tooltip"
|
|
||||||
@import "components/checkbox"
|
|
||||||
@import "components/overlay"
|
|
||||||
@import "components/card"
|
|
||||||
|
|
||||||
@import _notifications
|
|
||||||
@import _comments
|
|
||||||
|
|
||||||
@import _project
|
|
||||||
@import _project-sharing
|
|
||||||
@import _project-dashboard
|
|
||||||
@import _error
|
|
||||||
|
|
||||||
@import _search
|
|
||||||
|
|
||||||
@import plugins/_jstree
|
|
||||||
@import plugins/_js_select2
|
|
@@ -6,14 +6,15 @@
|
|||||||
| {% if node_type_name == 'group' %}
|
| {% if node_type_name == 'group' %}
|
||||||
| {% set node_type_name = 'folder' %}
|
| {% set node_type_name = 'folder' %}
|
||||||
| {% endif %}
|
| {% endif %}
|
||||||
li.dropdown-item(class="button-{{ node_type['name'] }}")
|
li(class="button-{{ node_type['name'] }}")
|
||||||
a.item_add_node(
|
a.dropdown-item(
|
||||||
|
class="item_add_node",
|
||||||
href="#",
|
href="#",
|
||||||
title="{{ node_type['description'] }}",
|
title="{{ node_type['description'] }}",
|
||||||
data-node-type-name="{{ node_type['name'] }}",
|
data-node-type-name="{{ node_type['name'] }}",
|
||||||
data-toggle="tooltip",
|
data-toggle="tooltip",
|
||||||
data-placement="left")
|
data-placement="left")
|
||||||
i.pi(class="icon-{{ node_type['name'] }}")
|
i.pi(class="pi-{{ node_type['name'] }}")
|
||||||
| {% if node_type_name == 'group_texture' %}
|
| {% if node_type_name == 'group_texture' %}
|
||||||
| Texture Folder
|
| Texture Folder
|
||||||
| {% elif node_type_name == 'group_hdri' %}
|
| {% elif node_type_name == 'group_hdri' %}
|
||||||
|
50
src/templates/_macros/_asset_list_item.pug
Normal file
50
src/templates/_macros/_asset_list_item.pug
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
| {% 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
|
||||||
|
.card-title.mb-1.font-weight-bold
|
||||||
|
| {{ asset.name }}
|
||||||
|
|
||||||
|
ul.card-text.list-unstyled.d-flex.text-black-50.mt-auto
|
||||||
|
li.pr-2 {{ node_type | undertitle }}
|
||||||
|
li {{ asset._created | pretty_date }}
|
||||||
|
|
||||||
|
| {% 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 %}
|
@@ -29,32 +29,20 @@ html(lang="en")
|
|||||||
meta(name="twitter:image", content="")
|
meta(name="twitter:image", content="")
|
||||||
| {% endblock %}
|
| {% endblock %}
|
||||||
|
|
||||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery-3.1.0.min.js')}}")
|
script(src="{{ url_for('static_pillar', filename='assets/js/tutti.min.js') }}")
|
||||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.typeahead-0.11.1.min.js')}}")
|
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.typeahead-0.11.1.min.js')}}")
|
||||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/js.cookie-2.0.3.min.js')}}")
|
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/js.cookie-2.0.3.min.js')}}")
|
||||||
| {% if current_user.is_authenticated %}
|
| {% if current_user.is_authenticated %}
|
||||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/clipboard.min.js')}}")
|
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/clipboard.min.js')}}")
|
||||||
| {% endif %}
|
| {% endif %}
|
||||||
|
|
||||||
script.
|
|
||||||
|
|
||||||
!function(e){"use strict";e.loadCSS=function(t,n,o){var r,i=e.document,l=i.createElement("link");if(n)r=n;else{var d=(i.body||i.getElementsByTagName("head")[0]).childNodes;r=d[d.length-1]}var a=i.styleSheets;l.rel="stylesheet",l.href=t,l.media="only x",r.parentNode.insertBefore(l,n?r:r.nextSibling);var f=function(e){for(var t=l.href,n=a.length;n--;)if(a[n].href===t)return e();setTimeout(function(){f(e)})};return l.onloadcssdefined=f,f(function(){l.media=o||"all"}),l},"undefined"!=typeof module&&(module.exports=e.loadCSS)}(this);
|
|
||||||
|
|
||||||
loadCSS( "//fonts.googleapis.com/css?family=Roboto:300,400" );
|
|
||||||
|
|
||||||
script(src="{{ url_for('static_pillar', filename='assets/js/markdown.min.js') }}")
|
|
||||||
script(src="{{ url_for('static_pillar', filename='assets/js/tutti.min.js') }}")
|
|
||||||
|
|
||||||
link(href="{{ url_for('static', filename='assets/img/favicon.png') }}", rel="shortcut icon")
|
link(href="{{ url_for('static', filename='assets/img/favicon.png') }}", rel="shortcut icon")
|
||||||
link(href="{{ url_for('static', filename='assets/img/apple-touch-icon-precomposed.png') }}", rel="icon apple-touch-icon-precomposed", sizes="192x192")
|
link(href="{{ url_for('static', filename='assets/img/apple-touch-icon-precomposed.png') }}", rel="icon apple-touch-icon-precomposed", sizes="192x192")
|
||||||
|
|
||||||
link(href="{{ url_for('static_pillar', filename='assets/css/vendor/bootstrap.min.css') }}", rel="stylesheet")
|
|
||||||
|
|
||||||
| {% block head %}{% endblock %}
|
| {% block head %}{% endblock %}
|
||||||
|
|
||||||
| {% block css %}
|
| {% block css %}
|
||||||
link(href="{{ url_for('static_pillar', filename='assets/css/font-pillar.css') }}", rel="stylesheet")
|
link(href="{{ url_for('static_pillar', filename='assets/css/font-pillar.css') }}", rel="stylesheet")
|
||||||
link(href="{{ url_for('static_pillar', filename='assets/css/base.css') }}", rel="stylesheet")
|
|
||||||
| {% if title == 'blog' %}
|
| {% if title == 'blog' %}
|
||||||
link(href="{{ url_for('static_pillar', filename='assets/css/blog.css') }}", rel="stylesheet")
|
link(href="{{ url_for('static_pillar', filename='assets/css/blog.css') }}", rel="stylesheet")
|
||||||
| {% else %}
|
| {% else %}
|
||||||
@@ -83,8 +71,6 @@ html(lang="en")
|
|||||||
| {% endblock footer %}
|
| {% endblock footer %}
|
||||||
| {% endblock footer_container%}
|
| {% endblock footer_container%}
|
||||||
|
|
||||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.bootstrap-3.3.7.min.js') }}")
|
|
||||||
|
|
||||||
| {% if current_user.is_authenticated %}
|
| {% if current_user.is_authenticated %}
|
||||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.typewatch-3.0.0.min.js') }}")
|
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.typewatch-3.0.0.min.js') }}")
|
||||||
script.
|
script.
|
||||||
|
@@ -1,7 +1,8 @@
|
|||||||
| {% if current_user.is_authenticated %}
|
| {% if current_user.is_authenticated %}
|
||||||
|
|
||||||
li.nav-notifications
|
li.nav-notifications.nav-item
|
||||||
a.navbar-item#notifications-toggle(
|
a.nav-link.px-2(
|
||||||
|
id="notifications-toggle",
|
||||||
title="Notifications",
|
title="Notifications",
|
||||||
data-toggle="tooltip",
|
data-toggle="tooltip",
|
||||||
data-placement="bottom")
|
data-placement="bottom")
|
||||||
|
@@ -12,28 +12,9 @@ li.dropdown
|
|||||||
ul.dropdown-menu.dropdown-menu-right
|
ul.dropdown-menu.dropdown-menu-right
|
||||||
| {% if not current_user.has_role('protected') %}
|
| {% if not current_user.has_role('protected') %}
|
||||||
| {% block menu_list %}
|
| {% block menu_list %}
|
||||||
li
|
|
||||||
a.navbar-item(
|
|
||||||
href="{{ url_for('projects.home_project') }}"
|
|
||||||
title="Home")
|
|
||||||
| #[i.pi-home] Home
|
|
||||||
|
|
||||||
li
|
li
|
||||||
a.navbar-item(
|
a.navbar-item.px-2(
|
||||||
href="{{ url_for('projects.index') }}"
|
|
||||||
title="My Projects")
|
|
||||||
| #[i.pi-star] My Projects
|
|
||||||
|
|
||||||
| {% if current_user.has_organizations() %}
|
|
||||||
li
|
|
||||||
a.navbar-item(
|
|
||||||
href="{{ url_for('pillar.web.organizations.index') }}"
|
|
||||||
title="My Organizations")
|
|
||||||
| #[i.pi-users] My Organizations
|
|
||||||
| {% endif %}
|
|
||||||
|
|
||||||
li
|
|
||||||
a.navbar-item(
|
|
||||||
href="{{ url_for('settings.profile') }}"
|
href="{{ url_for('settings.profile') }}"
|
||||||
title="Settings")
|
title="Settings")
|
||||||
| #[i.pi-cog] Settings
|
| #[i.pi-cog] Settings
|
||||||
@@ -48,7 +29,7 @@ li.dropdown
|
|||||||
href="{{ url_for('users.logout') }}")
|
href="{{ url_for('users.logout') }}")
|
||||||
i.pi-log-out(title="Log Out")
|
i.pi-log-out(title="Log Out")
|
||||||
| Log out
|
| Log out
|
||||||
a.navbar-item.subitem(
|
a.navbar-item.subitem.pt-0(
|
||||||
href="{{ url_for('users.switch') }}")
|
href="{{ url_for('users.switch') }}")
|
||||||
i.pi-blank
|
i.pi-blank
|
||||||
| Not {{ current_user.full_name }}?
|
| Not {{ current_user.full_name }}?
|
||||||
|
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
|
||||||
|
|
@@ -33,8 +33,8 @@ script(type="text/javascript").
|
|||||||
} else if (node_type === 'group_hdri') {
|
} else if (node_type === 'group_hdri') {
|
||||||
node_type_str = 'HDRi Folder';
|
node_type_str = 'HDRi Folder';
|
||||||
}
|
}
|
||||||
$('a', '.button-edit').html('<i class="pi-edit button-edit-icon"></i> Edit ' + node_type_str);
|
|
||||||
$('a', '.button-delete').html('<i class="pi-trash button-delete-icon"></i>Delete ' + node_type_str);
|
$('a', '.button-delete').html('<i class="pr-2 pi-trash button-delete-icon"></i>Delete ' + node_type_str);
|
||||||
|
|
||||||
{% if parent %}
|
{% if parent %}
|
||||||
ProjectUtils.setProjectAttributes({parentNodeId: '{{parent._id}}'});
|
ProjectUtils.setProjectAttributes({parentNodeId: '{{parent._id}}'});
|
||||||
@@ -114,7 +114,6 @@ script(type="text/javascript").
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
$(page_overlay).find('.nav-prev').click(function(e){
|
$(page_overlay).find('.nav-prev').click(function(e){
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -133,10 +132,6 @@ script(type="text/javascript").
|
|||||||
$(this).removeClass('active').hide().html();
|
$(this).removeClass('active').hide().html();
|
||||||
});
|
});
|
||||||
|
|
||||||
if (typeof $().popover != 'undefined'){
|
|
||||||
$('#asset-license').popover();
|
|
||||||
}
|
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
var $content_type = $(".js-type");
|
var $content_type = $(".js-type");
|
||||||
|
@@ -23,7 +23,7 @@ section.node-preview.video
|
|||||||
|
|
||||||
| {% block node_download %}
|
| {% block node_download %}
|
||||||
| {% if node.file_variations %}
|
| {% if node.file_variations %}
|
||||||
button.btn.btn-outline-secondary.dropdown-toggle(
|
button.btn.btn-sm.btn-outline-primary.dropdown-toggle.px-3(
|
||||||
type="button",
|
type="button",
|
||||||
data-toggle="dropdown",
|
data-toggle="dropdown",
|
||||||
aria-haspopup="true",
|
aria-haspopup="true",
|
||||||
@@ -32,7 +32,7 @@ button.btn.btn-outline-secondary.dropdown-toggle(
|
|||||||
| Download
|
| Download
|
||||||
i.pi-angle-down.icon-dropdown-menu
|
i.pi-angle-down.icon-dropdown-menu
|
||||||
|
|
||||||
ul.dropdown-menu
|
ul.dropdown-menu.dropdown-menu-right
|
||||||
| {% for variation in node.file_variations %}
|
| {% for variation in node.file_variations %}
|
||||||
li
|
li
|
||||||
a(href="{{ variation.link }}",
|
a(href="{{ variation.link }}",
|
||||||
@@ -52,25 +52,49 @@ script(type="text/javascript").
|
|||||||
{% if node.video_sources %}
|
{% if node.video_sources %}
|
||||||
|
|
||||||
var videoPlayer = document.getElementById('videoplayer');
|
var videoPlayer = document.getElementById('videoplayer');
|
||||||
|
|
||||||
var options = {
|
var options = {
|
||||||
controlBar: {
|
controlBar: {
|
||||||
volumePanel: { inline: false }
|
volumePanel: { inline: false }
|
||||||
}
|
},
|
||||||
|
playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 4]
|
||||||
};
|
};
|
||||||
|
|
||||||
videojs.registerPlugin('analytics', function() {
|
videojs(videoPlayer, options).ready(function() {
|
||||||
this.ga({
|
this.ga({
|
||||||
'eventLabel' : '{{ node._id }} - {{ node.name }}',
|
'eventLabel' : '{{ node._id }} - {{ node.name }}',
|
||||||
'eventCategory' : '{{ node.project }}',
|
'eventCategory' : '{{ node.project }}',
|
||||||
'eventsToTrack' : ['start', 'error', 'percentsPlayed']
|
'eventsToTrack' : ['start', 'error', 'percentsPlayed']
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.hotkeys({
|
||||||
|
enableVolumeScroll: false,
|
||||||
|
customKeys: {
|
||||||
|
KeyL: {
|
||||||
|
key: function(event) {
|
||||||
|
return (event.which === 76);
|
||||||
|
},
|
||||||
|
handler: function(player, options, event) {
|
||||||
|
videoPlayerToggleLoop(videoPlayer, videoPlayerLoopButton);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
videojs(videoPlayer, options).ready(function() {
|
this.rememberVolumePlugin();
|
||||||
this.hotkeys();
|
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
let fetch_progress_url = '{{ url_for("users_api.get_video_progress", video_id=node._id) }}';
|
||||||
|
let report_url = '{{ url_for("users_api.set_video_progress", video_id=node._id) }}';
|
||||||
|
|
||||||
|
this.progressPlugin({
|
||||||
|
'report_url': report_url,
|
||||||
|
'fetch_progress_url': fetch_progress_url,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generic utility to add-buttons to the player.
|
||||||
function addVideoPlayerButton(data) {
|
function addVideoPlayerButton(data) {
|
||||||
|
|
||||||
var controlBar,
|
var controlBar,
|
||||||
@@ -89,6 +113,7 @@ script(type="text/javascript").
|
|||||||
return newButton;
|
return newButton;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Video loop stuff. TODO: Move it to video_plugins.js
|
||||||
var videoPlayerLoopButton = addVideoPlayerButton({
|
var videoPlayerLoopButton = addVideoPlayerButton({
|
||||||
player: videoPlayer,
|
player: videoPlayer,
|
||||||
class: 'vjs-loop-button',
|
class: 'vjs-loop-button',
|
||||||
@@ -96,15 +121,18 @@ script(type="text/javascript").
|
|||||||
title: 'Loop'
|
title: 'Loop'
|
||||||
});
|
});
|
||||||
|
|
||||||
videoPlayerLoopButton.onclick = function() {
|
function videoPlayerToggleLoop(videoPlayer, videoPlayerLoopButton) {
|
||||||
if (videoPlayer.loop){
|
if (videoPlayer.loop){
|
||||||
videoPlayer.loop = false;
|
videoPlayer.loop = false;
|
||||||
$(this).removeClass('vjs-control-active');
|
$(videoPlayerLoopButton).removeClass('vjs-control-active');
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
videoPlayer.loop = true;
|
videoPlayer.loop = true;
|
||||||
$(this).addClass('vjs-control-active');
|
$(videoPlayerLoopButton).addClass('vjs-control-active');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
videoPlayerLoopButton.onclick = function() {
|
||||||
|
videoPlayerToggleLoop(videoPlayer, videoPlayerLoopButton);
|
||||||
};
|
};
|
||||||
|
|
||||||
{% endif %} // if node.video_sources
|
{% endif %} // if node.video_sources
|
||||||
|
@@ -1,8 +1,11 @@
|
|||||||
| {% extends 'nodes/custom/blog/index.html' %}
|
| {% extends 'nodes/custom/blog/index.html' %}
|
||||||
| {% import 'nodes/custom/blog/_macros.html' as blogmacros %}
|
| {% import 'nodes/custom/blog/_macros.html' as blogmacros %}
|
||||||
|
|
||||||
| {% block project_context %}
|
| {% block body %}
|
||||||
#blog_container
|
.container
|
||||||
#blog_index-container.expand-image-links
|
.pt-5.pb-2
|
||||||
|
h2.text-uppercase.font-weight-bold.text-center
|
||||||
|
| {{ project.name }} Blog Archive
|
||||||
|
|
||||||
| {{ blogmacros.render_archive(project, posts, posts_meta) }}
|
| {{ blogmacros.render_archive(project, posts, posts_meta) }}
|
||||||
| {% endblock project_context%}
|
| {% endblock body %}
|
||||||
|
@@ -1,9 +0,0 @@
|
|||||||
| {% extends 'nodes/custom/blog/index_main_project.html' %}
|
|
||||||
| {% import 'nodes/custom/blog/_macros.html' as blogmacros %}
|
|
||||||
|
|
||||||
| {% block body %}
|
|
||||||
.container
|
|
||||||
h3 Blog Archive
|
|
||||||
|
|
||||||
| {{ blogmacros.render_archive(project, posts, posts_meta) }}
|
|
||||||
| {% endblock body %}
|
|
@@ -1,55 +1,40 @@
|
|||||||
| {% extends 'projects/view.html' %}
|
| {% extends 'layout.html' %}
|
||||||
| {% import 'nodes/custom/blog/_macros.html' as blogmacros %}
|
| {% import 'nodes/custom/blog/_macros.html' as blogmacros %}
|
||||||
|
| {% from 'projects/_macros.html' import render_secondary_navigation %}
|
||||||
|
|
||||||
| {% set title = 'blog' %}
|
| {% set title = 'blog' %}
|
||||||
|
|
||||||
| {% block page_title %}Blog{% endblock%}
|
| {% block page_title %}Blog{% endblock%}
|
||||||
|
|
||||||
| {% block css %}
|
| {% block navigation_tabs %}
|
||||||
| {{ super() }}
|
| {{ render_secondary_navigation(project, navigation_links, title) }}
|
||||||
link(href="{{ url_for('static_pillar', filename='assets/css/blog.css') }}", rel="stylesheet")
|
| {% endblock navigation_tabs %}
|
||||||
| {% endblock %}
|
|
||||||
|
|
||||||
| {% block project_context %}
|
| {% block body %}
|
||||||
| {{ blogmacros.render_blog_index(project, posts, can_create_blog_posts, api, more_posts_available, posts_meta) }}
|
| {{ blogmacros.render_blog_index(node, project, posts, can_create_blog_posts, api, more_posts_available, posts_meta, pages=pages) }}
|
||||||
| {% endblock %}
|
|
||||||
|
|
||||||
| {% block project_tree %}
|
|
||||||
#project_tree.jstree.jstree-default.blog
|
|
||||||
ul.jstree-container-ul.jstree-children
|
|
||||||
li.jstree-node(data-node-type="page")
|
|
||||||
a.jstree-anchor(
|
|
||||||
href="{{ url_for('projects.view', project_url=project.url) }}")
|
|
||||||
| Browse Project
|
|
||||||
|
|
||||||
li.jstree-node(data-node-type="page")
|
|
||||||
a.jstree-anchor.jstree-clicked(
|
|
||||||
href="{{ url_for('main.project_blog', project_url=project.url) }}") Blog
|
|
||||||
|
|
||||||
| {% for post in posts %}
|
|
||||||
li.jstree-node
|
|
||||||
a.jstree-anchor.tree-item.post(
|
|
||||||
href="{{ node.url }}")
|
|
||||||
.tree-item-thumbnail
|
|
||||||
| {% if post.picture %}
|
|
||||||
img(src="{{ post.picture.thumbnail('s', api=api) }}")
|
|
||||||
| {% else %}
|
|
||||||
i.pi-document-text
|
|
||||||
| {% endif %}
|
|
||||||
span.tree-item-title {{ post.name }}
|
|
||||||
span.tree-item-info {{ post._created | pretty_date }}
|
|
||||||
| {% endfor %}
|
|
||||||
| {% endblock %}
|
| {% endblock %}
|
||||||
|
|
||||||
| {% block footer_scripts %}
|
| {% block footer_scripts %}
|
||||||
|
|
||||||
include ../_scripts
|
include ../_scripts
|
||||||
script.
|
script.
|
||||||
/* UI Stuff */
|
hopToTop(); // Display jump to top button
|
||||||
var project_container = document.getElementById('project-container');
|
|
||||||
|
|
||||||
$(window).on("load resize",function(){
|
/* Expand images when their link points to a jpg/png/gif */
|
||||||
containerResizeY($(window).height());
|
/* TODO: De-duplicate code from view post */
|
||||||
|
var page_overlay = document.getElementById('page-overlay');
|
||||||
|
$('.item-content a img').on('click', function(e){
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
if ($(window).width() > 480) {
|
var href = $(this).parent().attr('href');
|
||||||
project_container.style.height = (window.innerHeight - project_container.offsetTop) + "px";
|
var src = $(this).attr('src');
|
||||||
|
|
||||||
|
if (href.match("jpg$") || href.match("png$") || href.match("gif$")) {
|
||||||
|
$(page_overlay)
|
||||||
|
.addClass('active')
|
||||||
|
.html('<img src="' + src + '"/>');
|
||||||
|
} else {
|
||||||
|
window.location.href = href;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -1,40 +0,0 @@
|
|||||||
| {% extends 'layout.html' %}
|
|
||||||
| {% import 'nodes/custom/blog/_macros.html' as blogmacros %}
|
|
||||||
| {% set title = 'blog' %}
|
|
||||||
|
|
||||||
| {% block page_title %}Blog{% endblock%}
|
|
||||||
|
|
||||||
| {% block css %}
|
|
||||||
| {{ super() }}
|
|
||||||
link(href="{{ url_for('static_cloud', filename='assets/css/project-landing.css') }}", rel="stylesheet")
|
|
||||||
| {% endblock css %}
|
|
||||||
|
|
||||||
| {% block body %}
|
|
||||||
| {{ blogmacros.render_blog_index(project, posts, can_create_blog_posts, api, more_posts_available, posts_meta, pages=pages) }}
|
|
||||||
| {% endblock %}
|
|
||||||
|
|
||||||
| {% block footer_scripts %}
|
|
||||||
|
|
||||||
include ../_scripts
|
|
||||||
script.
|
|
||||||
hopToTop(); // Display jump to top button
|
|
||||||
|
|
||||||
/* Expand images when their link points to a jpg/png/gif */
|
|
||||||
/* TODO: De-duplicate code from view post */
|
|
||||||
var page_overlay = document.getElementById('page-overlay');
|
|
||||||
$('.item-content a img').on('click', function(e){
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
var href = $(this).parent().attr('href');
|
|
||||||
var src = $(this).attr('src');
|
|
||||||
|
|
||||||
if (href.match("jpg$") || href.match("png$") || href.match("gif$")) {
|
|
||||||
$(page_overlay)
|
|
||||||
.addClass('active')
|
|
||||||
.html('<img src="' + src + '"/>');
|
|
||||||
} else {
|
|
||||||
window.location.href = href;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
| {% endblock %}
|
|
@@ -5,11 +5,14 @@
|
|||||||
class="{% if is_reply %}is-reply{% else %}is-first{% endif %}")
|
class="{% if is_reply %}is-reply{% else %}is-first{% endif %}")
|
||||||
|
|
||||||
.comment-avatar
|
.comment-avatar
|
||||||
img(src="{{ comment._user.email | gravatar }}")
|
img(src="{{ comment._user.email | gravatar }}", alt="{{ comment._user.full_name }}")
|
||||||
|
|
||||||
.comment-content
|
.comment-content
|
||||||
.comment-body
|
.comment-body
|
||||||
p.comment-author {{ comment._user.full_name }}
|
p.comment-author {{ comment._user.full_name }}
|
||||||
|
//- TODO(Pablo): due to the broad styling done on the .comment-content class the
|
||||||
|
//- styling for the badges that I put in _project.sass isn't applied properly here.
|
||||||
|
| {{ comment._user.badges_html|safe }}
|
||||||
|
|
||||||
span {{comment.properties | markdowned('content') }}
|
span {{comment.properties | markdowned('content') }}
|
||||||
|
|
||||||
|
@@ -1,116 +1,30 @@
|
|||||||
|
| {% from '_macros/_asset_list_item.html' import asset_list_item %}
|
||||||
|
include ../../../mixins/components
|
||||||
|
|
||||||
| {% block body %}
|
| {% block body %}
|
||||||
#node-container
|
#node-container
|
||||||
|
section.d-flex
|
||||||
|
h4.p-4 {{node.name}}
|
||||||
|
|
||||||
section.node-details-container
|
| {% if node.description %}
|
||||||
.node-details-header
|
section.node-details-description.px-4
|
||||||
.node-title#node-title
|
| {{ node | markdowned('description') }}
|
||||||
| {{node.name}}
|
| {% endif %}
|
||||||
|
|
||||||
.node-details-meta-actions
|
section.container-fluid
|
||||||
.btn-browsetoggle(
|
| {% if children %}
|
||||||
|
|
||||||
|
.d-flex.justify-content-end.mb-2
|
||||||
|
button.btn.btn-sm.btn-outline-secondary(
|
||||||
|
class="js-btn-browsetoggle",
|
||||||
title="Toggle between list/grid view",
|
title="Toggle between list/grid view",
|
||||||
data-toggle="tooltip",
|
data-toggle="tooltip",
|
||||||
data-placement="top")
|
data-placement="top")
|
||||||
i.pi-list
|
i.pi-list
|
||||||
|
|
||||||
| {% if node.description %}
|
+card-deck(class="px-2")
|
||||||
.node-details-description
|
|
||||||
| {{ node | markdowned('description') }}
|
|
||||||
| {% endif %}
|
|
||||||
|
|
||||||
|
|
||||||
section.node-children.group
|
|
||||||
|
|
||||||
| {% if children %}
|
|
||||||
| {% for child in children %}
|
| {% for child in children %}
|
||||||
|
| {{ asset_list_item(child, current_user) }}
|
||||||
| {# Browse type: List #}
|
|
||||||
a(
|
|
||||||
href="{{ url_for_node(node=child) }}",
|
|
||||||
data-node_id="{{ child._id }}",
|
|
||||||
class="item_icon list-node-children-item browse-list")
|
|
||||||
.list-node-children-item-thumbnail
|
|
||||||
|
|
||||||
| {% if child.picture %}
|
|
||||||
img(
|
|
||||||
src="{{ child.picture.thumbnail('t', api=api)}} ")
|
|
||||||
| {% else %}
|
|
||||||
.cloud-logo
|
|
||||||
i.pi-blender-cloud
|
|
||||||
| {% endif %}
|
|
||||||
|
|
||||||
| {% if child.permissions.world %}
|
|
||||||
.list-node-children-item-ribbon
|
|
||||||
span free
|
|
||||||
| {% endif %}
|
|
||||||
|
|
||||||
.list-node-children-item-thumbnail-icon
|
|
||||||
| {% if child.properties.content_type and child.properties.content_type == 'video' %}
|
|
||||||
i.pi-play
|
|
||||||
| {% elif child.properties.content_type and child.properties.content_type == 'image' %}
|
|
||||||
i.pi-image
|
|
||||||
| {% elif child.properties.content_type and child.properties.content_type == 'file' %}
|
|
||||||
i.pi-file-archive
|
|
||||||
| {% else %}
|
|
||||||
i.pi-folder
|
|
||||||
| {% endif %}
|
|
||||||
|
|
||||||
.list-node-children-item-name {{ child.name }}
|
|
||||||
|
|
||||||
.list-node-children-item-meta
|
|
||||||
| {% if child.properties.status != 'published' %}
|
|
||||||
span.status {{ child.properties.status }}
|
|
||||||
| {% endif %}
|
|
||||||
|
|
||||||
span.type
|
|
||||||
| {% if child.properties.content_type %}
|
|
||||||
| {{ child.properties.content_type | undertitle }} ·
|
|
||||||
| {% elif child.node_type == 'group' %}
|
|
||||||
| Folder ·
|
|
||||||
| {% else %}
|
|
||||||
| {{ child.node_type | undertitle }} ·
|
|
||||||
| {% endif %}
|
|
||||||
|
|
||||||
span(title="Created on {{ child._created }}") {{ child._created | pretty_date }}
|
|
||||||
|
|
||||||
| {# Browse type: Icon #}
|
|
||||||
a(href="{{ url_for_node(node=child) }}",
|
|
||||||
data-node_id="{{ child._id }}",
|
|
||||||
title="{{ child.name }}",
|
|
||||||
class="item_icon list-node-children-item browse-icon {% if child.picture %}has-picture{% endif %}")
|
|
||||||
.list-node-children-item-thumbnail
|
|
||||||
|
|
||||||
| {% if child.picture %}
|
|
||||||
img(
|
|
||||||
src="{{ child.picture.thumbnail('m', api=api)}} ")
|
|
||||||
| {% else %}
|
|
||||||
.cloud-logo
|
|
||||||
i.pi-blender-cloud
|
|
||||||
| {% endif %}
|
|
||||||
|
|
||||||
.list-node-children-item-thumbnail-icon
|
|
||||||
| {% if child.properties.content_type and child.properties.content_type == 'video' %}
|
|
||||||
i.pi-play
|
|
||||||
| {% elif child.properties.content_type and child.properties.content_type == 'image' %}
|
|
||||||
i.pi-image
|
|
||||||
| {% elif child.properties.content_type and child.properties.content_type == 'file' %}
|
|
||||||
i.pi-file-archive
|
|
||||||
| {% else %}
|
|
||||||
i.pi-folder
|
|
||||||
| {% endif %}
|
|
||||||
|
|
||||||
| {% if child.properties.status != 'published' %}
|
|
||||||
.list-node-children-item-status {{ child.properties.status }}
|
|
||||||
| {% endif %}
|
|
||||||
|
|
||||||
| {% if child.permissions.world %}
|
|
||||||
.list-node-children-item-ribbon
|
|
||||||
span free
|
|
||||||
| {% endif %}
|
|
||||||
|
|
||||||
.list-node-children-item-name
|
|
||||||
span {{ child.name }}
|
|
||||||
|
|
||||||
| {% endfor %}
|
| {% endfor %}
|
||||||
| {% else %}
|
| {% else %}
|
||||||
.list-node-children-container
|
.list-node-children-container
|
||||||
@@ -121,7 +35,7 @@
|
|||||||
// Generate GA pageview
|
// Generate GA pageview
|
||||||
ga('send', 'pageview', location.pathname);
|
ga('send', 'pageview', location.pathname);
|
||||||
|
|
||||||
$('a.item_icon').unbind("click")
|
$('a.js-item-open').unbind("click")
|
||||||
.click(function(e){
|
.click(function(e){
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -149,15 +63,13 @@
|
|||||||
|
|
||||||
// Browse type: icon or list
|
// Browse type: icon or list
|
||||||
function projectBrowseTypeIcon() {
|
function projectBrowseTypeIcon() {
|
||||||
$(".list-node-children-item.browse-list").hide();
|
$(".card-deck").removeClass('card-deck-vertical');
|
||||||
$(".list-node-children-item.browse-icon").show();
|
$(".js-btn-browsetoggle").html('<i class="pi-list"></i> List View');
|
||||||
$(".btn-browsetoggle").html('<i class="pi-list"></i>');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function projectBrowseTypeList() {
|
function projectBrowseTypeList() {
|
||||||
$(".list-node-children-item.browse-list").show();
|
$(".card-deck").addClass('card-deck-vertical');
|
||||||
$(".list-node-children-item.browse-icon").hide();
|
$(".js-btn-browsetoggle").html('<i class="pi-layout"></i> Grid View');
|
||||||
$(".btn-browsetoggle").html('<i class="pi-layout"></i>');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function projectBrowseTypeCheck(){
|
function projectBrowseTypeCheck(){
|
||||||
@@ -197,7 +109,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$('.btn-browsetoggle').on('click', function (e) {
|
$('.js-btn-browsetoggle').on('click', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
projectBrowseToggle();
|
projectBrowseToggle();
|
||||||
});
|
});
|
||||||
|
@@ -1,29 +1,28 @@
|
|||||||
| {% extends 'projects/landing.html' %}
|
| {% extends 'projects/landing.html' %}
|
||||||
|
include ../../../mixins/components
|
||||||
|
|
||||||
| {% block body %}
|
| {% block body %}
|
||||||
|
.expand-image-links.imgs-fluid
|
||||||
| {% if node.picture %}
|
| {% if node.picture %}
|
||||||
header
|
+jumbotron(
|
||||||
img.header(src="{{ node.picture.thumbnail('h', api=api) }}")
|
"{{ node.name }}",
|
||||||
|
"{{ node._created | pretty_date }}{% if node.user.full_name %} · {{ node.user.full_name }}{% endif %}",
|
||||||
|
"{{ node.picture.thumbnail('h', api=api) }}",
|
||||||
|
"{{ node.url }}")
|
||||||
| {% endif %}
|
| {% endif %}
|
||||||
| {% block navbar_secondary %}
|
|
||||||
| {{ super() }}
|
|
||||||
| {% endblock navbar_secondary %}
|
|
||||||
#node-container
|
|
||||||
#node-overlay
|
|
||||||
|
|
||||||
section.node-details-container.page
|
.container.pb-5
|
||||||
|
.row
|
||||||
.node-details-header
|
.col-8.mx-auto
|
||||||
.node-title#node-title
|
h2.pt-5.pb-3.text-center {{node.name}}
|
||||||
| {{node.name}}
|
|
||||||
|
|
||||||
| {% if node.description %}
|
| {% if node.description %}
|
||||||
.node-details-description#node-description
|
.node-details-description
|
||||||
| {{ node | markdowned('description') }}
|
| {{ node | markdowned('description') }}
|
||||||
| {% endif %}
|
| {% endif %}
|
||||||
|
|
||||||
.node-details-meta.footer
|
small.text-muted
|
||||||
span.updated(title="created {{ node._created | pretty_date }}") updated {{ node._updated | pretty_date }}
|
span(title="created {{ node._created | pretty_date }}") Updated {{ node._updated | pretty_date }}
|
||||||
|
|
||||||
include ../_scripts
|
include ../_scripts
|
||||||
|
|
||||||
|
@@ -122,41 +122,38 @@ script(type="text/javascript").
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
var convert = new Markdown.getSanitizingConverter().makeHtml;
|
|
||||||
|
|
||||||
/* Build the markdown preview when typing in textarea */
|
/* Build the markdown preview when typing in textarea */
|
||||||
$(function() {
|
$(function() {
|
||||||
|
var $contentField = $('.form-group.description textarea'),
|
||||||
|
$contentPreview = $('<div class="node-edit-form-md-preview" />').insertAfter($contentField);
|
||||||
|
|
||||||
var $textarea = $('.form-group.content textarea'),
|
function parseDescriptionContent(content) {
|
||||||
$loader = $('<div class="md-preview-loading"><i class="pi-spin spin"></i></div>').insertAfter($textarea),
|
|
||||||
$preview = $('<div class="node-edit-form-md-preview" />').insertAfter($loader);
|
|
||||||
|
|
||||||
$loader.hide();
|
$.ajax({
|
||||||
|
url: "{{ url_for('nodes.preview_markdown')}}",
|
||||||
|
type: 'post',
|
||||||
|
data: {content: content},
|
||||||
|
headers: {"X-CSRFToken": csrf_token},
|
||||||
|
headers: {},
|
||||||
|
dataType: 'json'
|
||||||
|
})
|
||||||
|
.done(function (data) {
|
||||||
|
$contentPreview.html(data.content);
|
||||||
|
})
|
||||||
|
.fail(function (err) {
|
||||||
|
toastr.error(xhrErrorResponseMessage(err), 'Parsing failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Delay function to not start converting heavy posts immediately
|
var options = {
|
||||||
var delay = (function(){
|
callback: parseDescriptionContent,
|
||||||
var timer = 0;
|
wait: 750,
|
||||||
return function(callback, ms){
|
highlight: false,
|
||||||
clearTimeout (timer);
|
allowSubmit: false,
|
||||||
timer = setTimeout(callback, ms);
|
captureLength: 2
|
||||||
};
|
}
|
||||||
})();
|
|
||||||
|
|
||||||
$textarea.keyup(function() {
|
$contentField.typeWatch(options);
|
||||||
/* If there's an iframe (YouTube embed), delay markdown convert 1.5s */
|
|
||||||
if (/iframe/i.test($textarea.val())) {
|
|
||||||
$loader.show();
|
|
||||||
|
|
||||||
delay(function(){
|
|
||||||
// Convert markdown
|
|
||||||
$preview.html(convert($textarea.val()));
|
|
||||||
$loader.hide();
|
|
||||||
}, 1500 );
|
|
||||||
} else {
|
|
||||||
// Convert markdown
|
|
||||||
$preview.html(convert($textarea.val()));
|
|
||||||
};
|
|
||||||
}).trigger('keyup');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$(function() {
|
$(function() {
|
||||||
|
@@ -1,73 +0,0 @@
|
|||||||
| {% extends 'projects/view.html' %}
|
|
||||||
| {% set title = 'blog' %}
|
|
||||||
|
|
||||||
| {% block og %}
|
|
||||||
meta(property="og:title", content="{{ node.name }}")
|
|
||||||
meta(property="og:url", content="{{ url_for('main.project_blog', project_url=project.url, url=node.properties.url, _external=True)}}")
|
|
||||||
meta(property="og:type", content="website")
|
|
||||||
| {% if node.picture %}
|
|
||||||
meta(property="og:image", content="{{ node.picture.thumbnail('l', api=api) }}")
|
|
||||||
| {% endif %}
|
|
||||||
meta(property="og:description", content="Blender Cloud is a web based service developed by Blender Institute that allows people to access the training videos and all the data from the open projects.")
|
|
||||||
|
|
||||||
meta(name="twitter:title", content="{{ node.name }}")
|
|
||||||
meta(name="twitter:description", content="Blender Cloud is a web based service developed by Blender Institute that allows people to access the training videos and all the data from the open projects.")
|
|
||||||
| {% if node.picture %}
|
|
||||||
meta(property="og:image", content="{{ node.picture.thumbnail('l', api=api) }}")
|
|
||||||
| {% endif %}
|
|
||||||
| {% endblock %}
|
|
||||||
|
|
||||||
| {% block page_title %}{{node.name}} - Blog{% endblock%}
|
|
||||||
|
|
||||||
| {% block css %}
|
|
||||||
| {{ super() }}
|
|
||||||
link(href="{{ url_for('static_pillar', filename='assets/css/blog.css') }}", rel="stylesheet")
|
|
||||||
| {% endblock %}
|
|
||||||
|
|
||||||
| {% block project_context %}
|
|
||||||
| {% include 'nodes/custom/post/view_embed.html' %}
|
|
||||||
| {% endblock %}
|
|
||||||
|
|
||||||
| {% block project_tree %}
|
|
||||||
#project_tree.jstree.jstree-default.blog
|
|
||||||
ul.jstree-container-ul.jstree-children
|
|
||||||
li.jstree-node(data-node-type="page")
|
|
||||||
a.jstree-anchor(
|
|
||||||
href="{{ url_for('projects.view', project_url=project.url) }}")
|
|
||||||
| Browse Project
|
|
||||||
|
|
||||||
li.jstree-node(data-node-type="page")
|
|
||||||
a.jstree-anchor(
|
|
||||||
href="{{ url_for('main.project_blog', project_url=project.url) }}") Blog
|
|
||||||
|
|
||||||
| {% for post in posts %}
|
|
||||||
li.jstree-node
|
|
||||||
a.jstree-anchor.tree-item.post(
|
|
||||||
href="{{ url_for_node(node=post) }}",
|
|
||||||
class="{% if post._id == node._id %}jstree-clicked{% endif %}")
|
|
||||||
.tree-item-thumbnail
|
|
||||||
| {% if post.picture %}
|
|
||||||
img(src="{{ post.picture.thumbnail('s', api=api) }}")
|
|
||||||
| {% else %}
|
|
||||||
i.pi-document-text
|
|
||||||
| {% endif %}
|
|
||||||
span.tree-item-title {{ post.name }}
|
|
||||||
span.tree-item-info {{ post._created | pretty_date }}
|
|
||||||
| {% endfor %}
|
|
||||||
| {% endblock %}
|
|
||||||
|
|
||||||
| {% block footer_scripts %}
|
|
||||||
script.
|
|
||||||
ProjectUtils.setProjectAttributes({projectId: "{{project._id}}", isProject: false, nodeId: '{{node._id}}'});
|
|
||||||
|
|
||||||
/* UI Stuff */
|
|
||||||
var project_container = document.getElementById('project-container');
|
|
||||||
$(window).on("load resize",function(){
|
|
||||||
containerResizeY($(window).height());
|
|
||||||
|
|
||||||
if ($(window).width() > 480) {
|
|
||||||
project_container.style.height = (window.innerHeight - project_container.offsetTop) + "px";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
| {% endblock footer_scripts %}
|
|
@@ -1,9 +0,0 @@
|
|||||||
| {% import 'nodes/custom/blog/_macros.html' as blogmacros %}
|
|
||||||
|
|
||||||
| {{ blogmacros.render_blog_post(node, project=project) }}
|
|
||||||
|
|
||||||
#comments-embed.comments-compact
|
|
||||||
.comments-list-loading
|
|
||||||
i.pi-spin
|
|
||||||
|
|
||||||
include ../_scripts
|
|
@@ -18,11 +18,6 @@ meta(property="og:image", content="{{ node.picture.thumbnail('l', api=api) }}")
|
|||||||
|
|
||||||
| {% block page_title %}{{node.name}} - Blog{% endblock%}
|
| {% block page_title %}{{node.name}} - Blog{% endblock%}
|
||||||
|
|
||||||
| {% block css %}
|
|
||||||
| {{ super() }}
|
|
||||||
link(href="{{ url_for('static_cloud', filename='assets/css/project-landing.css') }}", rel="stylesheet")
|
|
||||||
| {% endblock css %}
|
|
||||||
|
|
||||||
| {% set title = 'blog' %}
|
| {% set title = 'blog' %}
|
||||||
|
|
||||||
| {% block body %}
|
| {% block body %}
|
||||||
|
@@ -113,7 +113,6 @@ include ../_scripts
|
|||||||
|
|
||||||
| {% block footer_scripts %}
|
| {% block footer_scripts %}
|
||||||
script.
|
script.
|
||||||
$('#asset-license').popover();
|
|
||||||
// Generate GA pageview
|
// Generate GA pageview
|
||||||
ga('send', 'pageview', location.pathname);
|
ga('send', 'pageview', location.pathname);
|
||||||
|
|
||||||
|
@@ -71,7 +71,7 @@
|
|||||||
|
|
||||||
hr
|
hr
|
||||||
|
|
||||||
ul.project-edit-tools.justify-content-end.h-auto
|
ul.project-edit-tools.disabled.d-flex.list-unstyled.p-2.mb-0.h-auto.justify-content-end
|
||||||
li.button-cancel
|
li.button-cancel
|
||||||
a#item_cancel.item-cancel.project-mode-edit.btn.btn-outline-secondary(
|
a#item_cancel.item-cancel.project-mode-edit.btn.btn-outline-secondary(
|
||||||
href="javascript:void(0);",
|
href="javascript:void(0);",
|
||||||
|
@@ -10,7 +10,7 @@
|
|||||||
| {{node.name}}
|
| {{node.name}}
|
||||||
|
|
||||||
| {% if node.description %}
|
| {% if node.description %}
|
||||||
.node-details-description#node-description
|
.node-details-description
|
||||||
| {{node.description}}
|
| {{node.description}}
|
||||||
| {% endif %}
|
| {% endif %}
|
||||||
|
|
||||||
|
@@ -1,4 +1,7 @@
|
|||||||
| {% extends 'layout.html' %}
|
| {% extends 'layout.html' %}
|
||||||
|
| {% from '_macros/_asset_list_item.html' import asset_list_item %}
|
||||||
|
include ../mixins/components
|
||||||
|
|
||||||
| {% block page_title %}Search{% if project %} {{ project.name }}{% endif %}{% endblock %}
|
| {% block page_title %}Search{% if project %} {{ project.name }}{% endif %}{% endblock %}
|
||||||
|
|
||||||
| {% block head %}
|
| {% block head %}
|
||||||
@@ -28,40 +31,28 @@ script.
|
|||||||
document.body.dataset["projectId"] = "{{project._id}}";
|
document.body.dataset["projectId"] = "{{project._id}}";
|
||||||
| {% endif %}
|
| {% endif %}
|
||||||
|
|
||||||
#search-container
|
|
||||||
|
|
||||||
| {% if project %}
|
| {% if project %}
|
||||||
#project_sidebar
|
#project_sidebar.bg-white
|
||||||
ul.project-tabs
|
ul.project-tabs.p-0
|
||||||
li.tabs-thumbnail(
|
|
||||||
title="About",
|
|
||||||
data-toggle="tooltip",
|
|
||||||
data-placement="left",
|
|
||||||
class="{% if title == 'about' %}active {% endif %}{% if project.picture_square %}image{% endif %}")
|
|
||||||
a(href="{{url_for('projects.view', project_url=project.url, _external=True)}}")
|
|
||||||
#project-loading
|
|
||||||
i.pi-spin
|
|
||||||
| {% if project.picture_square %}
|
|
||||||
img(src="{{ project.picture_square.thumbnail('b', api=api) }}")
|
|
||||||
| {% else %}
|
|
||||||
i.pi-home
|
|
||||||
| {% endif %}
|
|
||||||
li.tabs-browse(
|
li.tabs-browse(
|
||||||
title="Browse",
|
title="Browse",
|
||||||
data-toggle="tooltip",
|
data-toggle="tooltip",
|
||||||
data-placement="left")
|
data-placement="right")
|
||||||
a(href="{{url_for('projects.view', project_url=project.url, _external=True)}}")
|
a(href="{{url_for('projects.view', project_url=project.url, _external=True)}}")
|
||||||
i.pi-folder
|
i.pi-folder
|
||||||
|
|
||||||
li.tabs-search.active(
|
li.tabs-search.active(
|
||||||
title="Search",
|
title="Search",
|
||||||
data-toggle="tooltip",
|
data-toggle="tooltip",
|
||||||
data-placement="left")
|
data-placement="right")
|
||||||
a(href="{{url_for('projects.search', project_url=project.url, _external=True)}}")
|
a(href="{{url_for('projects.search', project_url=project.url, _external=True)}}")
|
||||||
i.pi-search
|
i.pi-search
|
||||||
| {% endif %}
|
| {% endif %}
|
||||||
|
|
||||||
#search-sidebar
|
|
||||||
input.search-field(
|
#search-container.d-flex(class="{% if project %}search-project{% endif %}")
|
||||||
|
.search-list
|
||||||
|
input.search-field.p-2.bg-white(
|
||||||
type="text",
|
type="text",
|
||||||
name="q",
|
name="q",
|
||||||
id="q",
|
id="q",
|
||||||
@@ -70,44 +61,29 @@ script.
|
|||||||
autocorrect="false",
|
autocorrect="false",
|
||||||
placeholder="Search by Title, Type...")
|
placeholder="Search by Title, Type...")
|
||||||
|
|
||||||
.search-list-filters
|
#pagination.mt-3
|
||||||
.filter-list
|
|
||||||
| View as:
|
|
||||||
ul.filter-list
|
|
||||||
li.filter-list-type.grid(
|
|
||||||
title="Browse as grid",
|
|
||||||
data-list-type="grid")
|
|
||||||
i.pi-layout
|
|
||||||
li.filter-list-type.list(
|
|
||||||
title="Browse as list",
|
|
||||||
data-list-type="list")
|
|
||||||
i.pi-list
|
|
||||||
|
|
||||||
#accordion.panel-group.accordion(role="tablist", aria-multiselectable="true")
|
//- #accordion.panel-group.accordion(role="tablist", aria-multiselectable="true")
|
||||||
#facets
|
#facets
|
||||||
|
|
||||||
#pagination
|
#stats.search-list-stats
|
||||||
|
|
||||||
.search-list-stats
|
+card-deck()(id='hits', class="h-100 m-0 pt-3 pr-2 card-deck-vertical")
|
||||||
#stats
|
|
||||||
|
|
||||||
#search-list
|
#search-details.border-left.search-details
|
||||||
#hits
|
|
||||||
|
|
||||||
#search-details
|
|
||||||
#search-error
|
#search-error
|
||||||
#search-hit-container
|
#search-hit-container.w-100
|
||||||
|
|
||||||
|
|
||||||
| {% raw %}
|
| {% raw %}
|
||||||
// Facet template
|
// Facet template
|
||||||
script(type="text/template", id="facet-template")
|
script(type="text/template", id="facet-template")
|
||||||
.panel.panel-default
|
.card
|
||||||
a(data-toggle='collapse', data-parent='#accordion', href='#filter_{{ facet }}', aria-expanded='true', aria-controls='filter_{{ facet }}')
|
a(data-toggle='collapse', data-parent='#accordion', href='#filter_{{ facet }}', aria-expanded='true', aria-controls='filter_{{ facet }}')
|
||||||
.panel-heading(role='tab')
|
.card-header(role='tab')
|
||||||
.panel-title {{ title }}
|
.card-title {{ title }}
|
||||||
.panel-collapse.collapse.in(id='filter_{{ facet }}', role='tabpanel', aria-labelledby='headingOne')
|
.collapse.show(id='filter_{{ facet }}', role='tabpanel', aria-labelledby='headingOne')
|
||||||
.panel-body
|
.card-body
|
||||||
| {{#values}}
|
| {{#values}}
|
||||||
a.facet_link.toggleRefine(
|
a.facet_link.toggleRefine(
|
||||||
class='{{#refined}}refined{{/refined}}',
|
class='{{#refined}}refined{{/refined}}',
|
||||||
@@ -122,38 +98,33 @@ script(type="text/template", id="facet-template")
|
|||||||
|
|
||||||
// Hit template
|
// Hit template
|
||||||
script(type="text/template", id="hit-template")
|
script(type="text/template", id="hit-template")
|
||||||
.search-hit(data-hit-id='{{ objectID }}')
|
a.card.asset.card-image-fade.pl-0.mx-0.mb-1(
|
||||||
#search-loading.search-loading
|
data-hit-id='{{ objectID }}',
|
||||||
.spinner
|
href="/nodes/{{ objectID }}/redir",
|
||||||
span.spin ·
|
class="js-search-hit {{#is_free}}free{{/is_free}}")
|
||||||
.search-hit-thumbnail
|
.embed-responsive.embed-responsive-16by9
|
||||||
| {{#picture}}
|
| {{#picture}}
|
||||||
img(src="{{{ picture }}}")
|
.card-img-top.embed-responsive-item(style="background-image: url({{{ picture }}})")
|
||||||
| {{/picture}}
|
| {{/picture}}
|
||||||
| {{^picture}}
|
| {{^picture}}
|
||||||
.search-hit-thumbnail-icon
|
.card-img-top.card-icon.embed-responsive-item
|
||||||
| {{#media}}
|
| {{#media}}
|
||||||
i(class="pi-{{{ media }}}")
|
i(class="pi-{{{ media }}}")
|
||||||
| {{/media}}
|
| {{/media}}
|
||||||
| {{^media}}
|
| {{^media}}
|
||||||
i.dark(class="pi-{{{ node_type }}}")
|
i(class="pi-{{{ node_type }}}")
|
||||||
| {{/media}}
|
| {{/media}}
|
||||||
| {{/picture}}
|
| {{/picture}}
|
||||||
| {{#is_free}}
|
.card-body.py-2.d-flex.flex-column
|
||||||
.search-hit-ribbon
|
.card-title.mb-1.font-weight-bold
|
||||||
span free
|
|
||||||
| {{/is_free}}
|
|
||||||
.search-hit-name
|
|
||||||
| {{ name }}
|
| {{ name }}
|
||||||
.search-hit-meta
|
|
||||||
span.project {{ project.name }}
|
ul.card-text.list-unstyled.d-flex.text-black-50.mt-auto
|
||||||
span.node_type {{{ node_type }}}
|
li.pr-2.project {{ project.name }}
|
||||||
| {{#media}}
|
| {{#media}}
|
||||||
span.media · {{{ media }}}
|
li.pr-2.text-capitalize {{{ media }}}
|
||||||
| {{/media}}
|
| {{/media}}
|
||||||
span.when {{{ created_at }}}
|
li.pr-2 {{{ created_at }}}
|
||||||
span.context
|
|
||||||
a(href="/nodes/{{ objectID }}/redir") view in context
|
|
||||||
|
|
||||||
|
|
||||||
// Pagination template
|
// Pagination template
|
||||||
@@ -174,7 +145,6 @@ script(type="text/template", id="stats-template")
|
|||||||
| {% endblock %}
|
| {% endblock %}
|
||||||
|
|
||||||
| {% block footer_scripts %}
|
| {% block footer_scripts %}
|
||||||
script(src="//releases.flowplayer.org/6.0.5/flowplayer.min.js", async)
|
|
||||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/hogan.common-3.0.0.js') }}")
|
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/hogan.common-3.0.0.js') }}")
|
||||||
script(src="{{ url_for('static_pillar', filename='assets/js/elasticsearch.min.js') }}")
|
script(src="{{ url_for('static_pillar', filename='assets/js/elasticsearch.min.js') }}")
|
||||||
|
|
||||||
@@ -186,31 +156,30 @@ script.
|
|||||||
$('#search-hit-container').html(dataHtml);
|
$('#search-hit-container').html(dataHtml);
|
||||||
})
|
})
|
||||||
.done(function(){
|
.done(function(){
|
||||||
$('.search-loading').removeClass('active');
|
$('.loader-bar').removeClass('active');
|
||||||
$('#search-error').hide();
|
$('#search-error').hide();
|
||||||
$('#search-hit-container').show();
|
$('#search-hit-container').show();
|
||||||
})
|
})
|
||||||
.fail(function(data){
|
.fail(function(data){
|
||||||
$('.search-loading').removeClass('active');
|
$('.loader-bar').removeClass('active');
|
||||||
$('#search-hit-container').hide();
|
$('#search-hit-container').hide();
|
||||||
$('#search-error').show().html('Houston!\n\n' + data.status + ' ' + data.statusText);
|
$('#search-error').show().html('Houston!\n\n' + data.status + ' ' + data.statusText);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$('body').on('click', '.search-hit', function(){
|
$('body').on('click', '.js-search-hit', function(e){
|
||||||
if ($('.search-loading').hasClass('active')){
|
e.preventDefault();
|
||||||
$(this).removeClass('active');
|
|
||||||
}
|
$('.loader-bar').removeClass('active').addClass('active');
|
||||||
$(this).find('#search-loading').addClass('active');
|
|
||||||
|
|
||||||
displayNode($(this).data('hit-id'));
|
displayNode($(this).data('hit-id'));
|
||||||
$('.search-hit').removeClass('active');
|
$('.js-search-hit').removeClass('active');
|
||||||
$(this).addClass('active');
|
$(this).addClass('active');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove focus from search input so that the click event bound to .search-hit
|
// Remove focus from search input so that the click event
|
||||||
// can be fired on the first click.
|
// bound to .search-hit can be fired on the first click.
|
||||||
$(searchList).hover(function(){
|
$('#search-list').hover(function(){
|
||||||
$('#q').blur();
|
$('#q').blur();
|
||||||
});
|
});
|
||||||
$('#search-sidebar').hover(function(){
|
$('#search-sidebar').hover(function(){
|
||||||
@@ -218,42 +187,6 @@ script.
|
|||||||
});
|
});
|
||||||
|
|
||||||
/* UI Stuff */
|
/* UI Stuff */
|
||||||
|
|
||||||
/* List types, grid or list (default)*/
|
|
||||||
var uiListType = Cookies.getJSON('bcloud_ui');
|
|
||||||
var searchList = document.getElementById('search-list');
|
|
||||||
|
|
||||||
function uiSetListType(type){
|
|
||||||
$('.filter-list-type').removeClass('active');
|
|
||||||
|
|
||||||
if (type == 'grid'){
|
|
||||||
$(searchList).addClass('view-grid');
|
|
||||||
$('.filter-list-type.grid').addClass('active');
|
|
||||||
} else {
|
|
||||||
$(searchList).removeClass('view-grid');
|
|
||||||
$('.filter-list-type.list').addClass('active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uiListType && uiListType.search_browse_type == 'grid'){
|
|
||||||
uiSetListType('grid');
|
|
||||||
} else {
|
|
||||||
uiSetListType('list');
|
|
||||||
}
|
|
||||||
|
|
||||||
$('.filter-list-type').on('click', function(){
|
|
||||||
if ($(this).attr('data-list-type') == 'grid'){
|
|
||||||
uiSetListType('grid');
|
|
||||||
setJSONCookie('bcloud_ui', 'search_browse_type', 'grid');
|
|
||||||
} else {
|
|
||||||
uiSetListType('list');
|
|
||||||
setJSONCookie('bcloud_ui', 'search_browse_type', 'list');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/* Hide site-wide search, kinda confusing */
|
|
||||||
$('.search-input').hide();
|
|
||||||
|
|
||||||
/* Resize container so we can have custom scrollbars */
|
/* Resize container so we can have custom scrollbars */
|
||||||
container_offset = $('#search-container').offset();
|
container_offset = $('#search-container').offset();
|
||||||
|
|
||||||
|
@@ -5,9 +5,8 @@
|
|||||||
| {% block node_preview %}
|
| {% block node_preview %}
|
||||||
| {% if node.picture %}
|
| {% if node.picture %}
|
||||||
| {% if current_user.has_cap('subscriber') or node.permissions.world %}
|
| {% if current_user.has_cap('subscriber') or node.permissions.world %}
|
||||||
section#node-preview.node-preview.image.js-node-preview-image
|
section.node-preview.image.js-node-preview-image
|
||||||
img.node-preview-thumbnail#node-preview-thumbnail(
|
img.node-preview-thumbnail(src="{{ node.picture.thumbnail('l', api=api) }}")
|
||||||
src="{{ node.picture.thumbnail('l', api=api) }}")
|
|
||||||
| {% else %}
|
| {% else %}
|
||||||
| {% include 'nodes/custom/_node_preview_forbidden.html' %}
|
| {% include 'nodes/custom/_node_preview_forbidden.html' %}
|
||||||
| {% endif %}
|
| {% endif %}
|
||||||
@@ -15,47 +14,41 @@
|
|||||||
| {% endblock node_preview %}
|
| {% endblock node_preview %}
|
||||||
|
|
||||||
| {% block node_details %}
|
| {% block node_details %}
|
||||||
section.node-details-container
|
|
||||||
|
|
||||||
| {# NAME #}
|
| {# NAME #}
|
||||||
.node-details-header
|
section.px-4
|
||||||
.node-title#node-title
|
h4.pt-4.mb-3 {{node.name}}
|
||||||
| {{node.name}}
|
|
||||||
|
|
||||||
|
|
||||||
| {# DESCRIPTION #}
|
| {# DESCRIPTION #}
|
||||||
| {% if node.description %}
|
| {% if node.description %}
|
||||||
.node-details-description#node-description
|
.node-details-description
|
||||||
| {{ node | markdowned('description') }}
|
| {{ node | markdowned('description') }}
|
||||||
| {% endif %}
|
| {% endif %}
|
||||||
|
|
||||||
|
| {# DETAILS #}
|
||||||
| {# LICENSE #}
|
section.node-details-meta.pl-4.pr-2.py-2.border-bottom
|
||||||
|
ul.list-unstyled.m-0
|
||||||
| {% if node.properties.license_type %}
|
| {% if node.properties.license_type %}
|
||||||
|
li.px-2
|
||||||
a.node-details-license(
|
a.node-details-license(
|
||||||
href="https://creativecommons.org/licenses/",
|
href="https://creativecommons.org/licenses/",
|
||||||
target="_blank")
|
target="_blank",
|
||||||
span.type
|
title="{{ node.properties.license_type }} {% if node.properties.license_notes %}{{ node.properties.license_notes }}{% endif %}",
|
||||||
|
data-toggle="tooltip",
|
||||||
|
data-placement="top")
|
||||||
i(class="pi-license-{{ node.properties.license_type }}")
|
i(class="pi-license-{{ node.properties.license_type }}")
|
||||||
| License <span>{{ node.properties.license_type }}</span>
|
|
||||||
| {% if node.properties.license_notes %}
|
|
||||||
| — {{ node.properties.license_notes }}
|
|
||||||
| {% endif %}
|
|
||||||
| {% endif %}
|
| {% endif %}
|
||||||
|
|
||||||
|
|
||||||
| {# DETAILS #}
|
|
||||||
.node-details-meta
|
|
||||||
ul
|
|
||||||
| {% if node.has_method('PUT') and (node.properties.status != 'published') %}
|
| {% if node.has_method('PUT') and (node.properties.status != 'published') %}
|
||||||
li(class="status-{{ node.properties.status }}")
|
li.px-2(class="status-{{ node.properties.status }}")
|
||||||
| {{ node.properties.status | undertitle }}
|
| {{ node.properties.status | undertitle }}
|
||||||
| {% endif %}
|
| {% endif %}
|
||||||
|
|
||||||
li(title="Author")
|
li.px-2(title="Author")
|
||||||
| {{ node.user.full_name }}
|
| {{ node.user.full_name }}
|
||||||
|
| {{ node.user.badges.html|safe }}
|
||||||
|
|
||||||
li(
|
li.px-2(
|
||||||
title="Created {{ node._created }} (updated {{ node._updated | pretty_date_time }})")
|
title="Created {{ node._created }} (updated {{ node._updated | pretty_date_time }})")
|
||||||
| {{ node._created | pretty_date }}
|
| {{ node._created | pretty_date }}
|
||||||
|
|
||||||
@@ -66,12 +59,12 @@
|
|||||||
| Shared
|
| Shared
|
||||||
| {% endif %}
|
| {% endif %}
|
||||||
|
|
||||||
li.left-side
|
li.ml-auto
|
||||||
|
|
||||||
| {% if node.file %}
|
| {% if node.file %}
|
||||||
li(title="File size")
|
li.px-2(title="File size")
|
||||||
| {{ node.file.length | filesizeformat }}
|
| {{ node.file.length | filesizeformat }}
|
||||||
li.js-type(title="File format")
|
li.px-2.js-type(title="File format")
|
||||||
| {{ node.file.content_type }}
|
| {{ node.file.content_type }}
|
||||||
| {% endif %}
|
| {% endif %}
|
||||||
|
|
||||||
@@ -93,17 +86,17 @@
|
|||||||
title="Download {{ node.properties.content_type | undertitle }}",
|
title="Download {{ node.properties.content_type | undertitle }}",
|
||||||
href="{{ node.file.link }}",
|
href="{{ node.file.link }}",
|
||||||
download="{{ node.file.filename }}")
|
download="{{ node.file.filename }}")
|
||||||
button.btn(type="button")
|
button.btn.btn-sm.btn-outline-primary.px-3(type="button")
|
||||||
i.pi-download
|
i.pi-download.pr-2
|
||||||
| Download
|
| Download
|
||||||
| {% endblock node_download %}
|
| {% endblock node_download %}
|
||||||
|
|
||||||
| {% elif current_user.has_cap('can-renew-subscription') %}
|
| {% elif current_user.has_cap('can-renew-subscription') %}
|
||||||
a.btn.btn-success(
|
a.btn.btn-outline-primary(
|
||||||
title="Renew your subscription to download",
|
title="Renew your subscription to download",
|
||||||
target="_blank",
|
target="_blank",
|
||||||
href="/renew")
|
href="/renew")
|
||||||
i.pi-heart
|
i.pi-heart.pr-2
|
||||||
| Renew Subscription
|
| Renew Subscription
|
||||||
|
|
||||||
| {% elif current_user.is_authenticated %}
|
| {% elif current_user.is_authenticated %}
|
||||||
@@ -120,13 +113,33 @@
|
|||||||
| {% endif %}
|
| {% endif %}
|
||||||
|
|
||||||
| {% endblock node_details %}
|
| {% endblock node_details %}
|
||||||
|
.container-fluid
|
||||||
|
.row
|
||||||
| {% block node_comments %}
|
| {% block node_comments %}
|
||||||
|
.col-md-8.col-sm-12
|
||||||
#comments-embed
|
#comments-embed
|
||||||
.comments-list-loading
|
.comments-list-loading
|
||||||
i.pi-spin
|
i.pi-spin
|
||||||
| {% endblock node_comments %}
|
| {% endblock node_comments %}
|
||||||
|
|
||||||
|
| {% if node.properties.tags %}
|
||||||
|
.col-md-4.d-none.d-lg-block
|
||||||
|
script(src="{{ url_for('static_cloud', filename='assets/js/tagged_assets.min.js') }}")
|
||||||
|
script.
|
||||||
|
$(function() {
|
||||||
|
$('.js-asset-list').loadTaggedAssets(4, 0);
|
||||||
|
})
|
||||||
|
.tagged-similar.p-3
|
||||||
|
h6 Similar assets
|
||||||
|
| {% for tag in node.properties.tags[:3] %}
|
||||||
|
| {% if loop.index < 4 %}
|
||||||
|
.card-deck.card-padless.card-deck-vertical.mx-0(
|
||||||
|
class="js-asset-list",
|
||||||
|
data-asset-tag="{{ tag }}")
|
||||||
|
| {% endif %}
|
||||||
|
| {% endfor %}
|
||||||
|
| {% endif %}
|
||||||
|
|
||||||
| {% include 'nodes/custom/_scripts.html' %}
|
| {% include 'nodes/custom/_scripts.html' %}
|
||||||
|
|
||||||
| {% endblock %}
|
| {% endblock %}
|
||||||
|
@@ -1,29 +1,38 @@
|
|||||||
| {% macro render_secondary_navigation(project, pages=None) %}
|
include ../mixins/components
|
||||||
nav.navbar-secondary
|
|
||||||
nav.collapse.navbar-collapse
|
| {% macro render_secondary_navigation(project, navigation_links, title) %}
|
||||||
ul.navbar-nav.navbar-right
|
|
||||||
li
|
| {% if project.category == 'course' %}
|
||||||
a.navbar-item(
|
| {% set category_url = url_for('cloud.courses') %}
|
||||||
href="{{ url_for('projects.view', project_url=project.url) }}",
|
| {% elif project.category == 'workshop' %}
|
||||||
title="{{ project.name }} Homepage")
|
| {% set category_url = url_for('cloud.workshops') %}
|
||||||
span
|
| {% elif project.category == 'film' %}
|
||||||
b {{ project.name }}
|
| {% set category_url = url_for('cloud.open_projects') %}
|
||||||
li
|
| {% else %}
|
||||||
a.navbar-item(
|
| {% set category_url = url_for('main.homepage') %}
|
||||||
href="{{ url_for('main.project_blog', project_url=project.url) }}",
|
|
||||||
title="Project Blog",
|
|
||||||
class="{% if category == 'blog' %}active{% endif %}")
|
|
||||||
span Blog
|
|
||||||
| {% if pages %}
|
|
||||||
| {% for p in pages %}
|
|
||||||
li
|
|
||||||
a.navbar-item(
|
|
||||||
href="{{ url_for('projects.view_node', project_url=project.url, node_id=p._id) }}",
|
|
||||||
title="{{ p.name }}",
|
|
||||||
class="{% if category == 'page' %}active{% endif %}")
|
|
||||||
span {{ p.name }}
|
|
||||||
| {% endfor %}
|
|
||||||
| {% endif %}
|
| {% endif %}
|
||||||
|
|
||||||
|
+nav-secondary()
|
||||||
|
| {% if project.url != 'blender-cloud' %}
|
||||||
|
| {% if not project.is_private %}
|
||||||
|
li.text-capitalize
|
||||||
|
a.nav-link.text-muted.px-0(href="{{ category_url }}")
|
||||||
|
span {{ project.category }}
|
||||||
|
li.px-1
|
||||||
|
i.pi-angle-right
|
||||||
|
| {% endif %}
|
||||||
|
|
||||||
|
+nav-secondary-link(
|
||||||
|
class="px-1 font-weight-bold",
|
||||||
|
href="{{url_for('projects.view', project_url=project.url, _external=True)}}")
|
||||||
|
span {{ project.name }}
|
||||||
|
| {% endif %}
|
||||||
|
|
||||||
|
| {% for link in navigation_links %}
|
||||||
|
+nav-secondary-link(href="{{ link['url'] }}")
|
||||||
|
| {{ link['label'] }}
|
||||||
|
| {% endfor %}
|
||||||
|
|
||||||
| {% if project.nodes_featured %}
|
| {% if project.nodes_featured %}
|
||||||
| {# In some cases featured_nodes might might be embedded #}
|
| {# In some cases featured_nodes might might be embedded #}
|
||||||
| {% if '_id' in project.nodes_featured[0] %}
|
| {% if '_id' in project.nodes_featured[0] %}
|
||||||
@@ -31,11 +40,11 @@ nav.navbar-secondary
|
|||||||
| {% else %}
|
| {% else %}
|
||||||
| {% set featured_node_id=project.nodes_featured[0] %}
|
| {% set featured_node_id=project.nodes_featured[0] %}
|
||||||
| {% endif %}
|
| {% endif %}
|
||||||
li
|
+nav-secondary-link(
|
||||||
a.navbar-item(
|
|
||||||
href="{{ url_for('projects.view_node', project_url=project.url, node_id=featured_node_id) }}",
|
href="{{ url_for('projects.view_node', project_url=project.url, node_id=featured_node_id) }}",
|
||||||
title="Explore {{ project.name }}",
|
title="Explore {{ project.name }}",
|
||||||
class="{% if category == 'blog' %}active{% endif %}")
|
class="{% if title == 'project' %}active{% endif %}")
|
||||||
span Explore
|
span Explore
|
||||||
| {% endif %}
|
| {% endif %}
|
||||||
|
|
||||||
| {% endmacro %}
|
| {% endmacro %}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user