Compare commits
	
		
			179 Commits
		
	
	
		
			wip-open-p
			...
			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 | |||
| 0baf5b38c3 | |||
| 858a75af8d | |||
| 6b1a5e24e8 | |||
| 1500e20291 | |||
| d347534fea | |||
| 4546469d37 | |||
| b0d8da821f | |||
| 1821bb6b7d | |||
| 278eebd235 | |||
| 2777c37085 | |||
| bc16bb6e56 | |||
| 0fcafddbd1 | |||
| f29e01c78e | |||
| 2698be3e12 | |||
| 9c2ded79dd | |||
| b4acfb89fa | |||
| 3f8e0396cf | |||
| 05c488c484 | |||
| 33bd2c5880 | |||
| 76338b4568 | |||
| 7405e198eb | |||
| 2332bc0960 | |||
| ac3a599bb6 | |||
| 814275fc95 | |||
| 40c19a3cb0 | |||
| a67527d6af | |||
| 791906521f | |||
| 2ad5b20880 | |||
| f6fd9228e5 | |||
| e9f303f330 | |||
| 00a7406a1e | |||
| 82aa521b5f | |||
| f7220924bc | |||
| 46b0d6d663 | |||
| 595bb48741 | |||
| 1c430044b9 | |||
| 73bc084417 | |||
| 37ca803162 | |||
| 939bb97f13 | |||
| 2c40665271 | |||
| e8123b7839 | |||
| 6d6a40b8c0 | |||
| efd345ec46 | |||
| d655d2b749 | |||
| a58e616769 | |||
| a8a7166e78 | |||
| 1649591d75 | |||
| 9389fef8ba | |||
| 6737aa1123 | |||
| 40f79af49d | |||
| 84608500b9 | |||
| 819300f954 | |||
| b569829343 | |||
| c35fb6202b | |||
| d0ff519980 | |||
| 6ff4ee8fa1 | |||
| b5535a8773 | |||
| 2ded541955 | |||
| 3965061bde | |||
| 5238e2c26d | |||
| 469f24d113 | |||
| 8a0f582a80 | |||
| 559e212c55 | |||
| 61278730c6 | |||
| 0fdcbc3947 | |||
| 8dc3296bd5 | |||
| a699138fd6 | |||
| 466adabbb0 | |||
| 7da741f354 | |||
| 41369d134c | |||
| 61ed083218 | |||
| 46777f7f8c | |||
| ef94c68177 | |||
| aaf452e18b | |||
| c607eaf23d | |||
| baa77a7de5 | |||
| 5fb40eb32b | |||
| c83a1a21b8 | |||
| 549cf0a3e8 | |||
| 9f380751f5 | |||
| 49075cbc60 | |||
| 81848c2c44 | |||
| 9ee7b742ab | |||
| 58c33074c3 | |||
| 756427b34e | |||
| 7e06212cd5 | |||
| ef3912b647 | |||
| 151484dee3 | |||
| bec1f209ba | |||
| 0e14bdd09f | |||
| ce6df542cc | |||
| 530302b74f | |||
| 1bfb6cd2f6 | |||
| 53b6210531 | |||
| aeaa03ed80 | 
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -12,6 +12,7 @@ config_local.py
 | 
			
		||||
 | 
			
		||||
/build
 | 
			
		||||
/.cache
 | 
			
		||||
/.pytest_cache/
 | 
			
		||||
/*.egg-info/
 | 
			
		||||
profile.stats
 | 
			
		||||
/dump/
 | 
			
		||||
@@ -26,6 +27,7 @@ profile.stats
 | 
			
		||||
 | 
			
		||||
pillar/web/static/assets/css/*.css
 | 
			
		||||
pillar/web/static/assets/js/*.min.js
 | 
			
		||||
pillar/web/static/assets/js/vendor/video.min.js
 | 
			
		||||
pillar/web/static/storage/
 | 
			
		||||
pillar/web/static/uploads/
 | 
			
		||||
pillar/web/templates/
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										58
									
								
								gulpfile.js
									
									
									
									
									
								
							
							
						
						
									
										58
									
								
								gulpfile.js
									
									
									
									
									
								
							@@ -12,15 +12,16 @@ var pug          = require('gulp-pug');
 | 
			
		||||
var rename       = require('gulp-rename');
 | 
			
		||||
var sass         = require('gulp-sass');
 | 
			
		||||
var sourcemaps   = require('gulp-sourcemaps');
 | 
			
		||||
var uglify       = require('gulp-uglify');
 | 
			
		||||
var uglify       = require('gulp-uglify-es').default;
 | 
			
		||||
 | 
			
		||||
var enabled = {
 | 
			
		||||
    uglify: argv.production,
 | 
			
		||||
    maps: argv.production,
 | 
			
		||||
    maps: !argv.production,
 | 
			
		||||
    failCheck: !argv.production,
 | 
			
		||||
    prettyPug: !argv.production,
 | 
			
		||||
    cachify: !argv.production,
 | 
			
		||||
    cleanup: argv.production,
 | 
			
		||||
    chmod: argv.production,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
var destination = {
 | 
			
		||||
@@ -29,6 +30,11 @@ var destination = {
 | 
			
		||||
    js: 'pillar/web/static/assets/js',
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var source = {
 | 
			
		||||
    bootstrap: 'node_modules/bootstrap/',
 | 
			
		||||
    jquery: 'node_modules/jquery/',
 | 
			
		||||
    popper: 'node_modules/popper.js/'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* CSS */
 | 
			
		||||
gulp.task('styles', function() {
 | 
			
		||||
@@ -67,36 +73,50 @@ gulp.task('scripts', function() {
 | 
			
		||||
        .pipe(gulpif(enabled.uglify, uglify()))
 | 
			
		||||
        .pipe(rename({suffix: '.min'}))
 | 
			
		||||
        .pipe(gulpif(enabled.maps, sourcemaps.write(".")))
 | 
			
		||||
        .pipe(chmod(644))
 | 
			
		||||
        .pipe(gulpif(enabled.chmod, chmod(644)))
 | 
			
		||||
        .pipe(gulp.dest(destination.js))
 | 
			
		||||
        .pipe(gulpif(argv.livereload, livereload()));
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/* Collection of scripts in src/scripts/tutti/ to merge into tutti.min.js */
 | 
			
		||||
/* Since it's always loaded, it's only for functions that we want site-wide */
 | 
			
		||||
/* Collection of scripts in src/scripts/tutti/ to merge into tutti.min.js
 | 
			
		||||
 * Since it's always loaded, it's only for functions that we want site-wide.
 | 
			
		||||
 * It also includes jQuery and Bootstrap (and its dependency popper), since
 | 
			
		||||
 * the site doesn't work without it anyway.*/
 | 
			
		||||
gulp.task('scripts_concat_tutti', function() {
 | 
			
		||||
    gulp.src('src/scripts/tutti/**/*.js')
 | 
			
		||||
 | 
			
		||||
    toUglify = [
 | 
			
		||||
        source.jquery    + 'dist/jquery.min.js',
 | 
			
		||||
        source.popper    + 'dist/umd/popper.min.js',
 | 
			
		||||
        source.bootstrap + 'js/dist/index.js',
 | 
			
		||||
        source.bootstrap + 'js/dist/util.js',
 | 
			
		||||
        source.bootstrap + 'js/dist/tooltip.js',
 | 
			
		||||
        source.bootstrap + 'js/dist/dropdown.js',
 | 
			
		||||
        'src/scripts/tutti/**/*.js'
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    gulp.src(toUglify)
 | 
			
		||||
        .pipe(gulpif(enabled.failCheck, plumber()))
 | 
			
		||||
        .pipe(gulpif(enabled.maps, sourcemaps.init()))
 | 
			
		||||
        .pipe(concat("tutti.min.js"))
 | 
			
		||||
        .pipe(gulpif(enabled.uglify, uglify()))
 | 
			
		||||
        .pipe(gulpif(enabled.maps, sourcemaps.write(".")))
 | 
			
		||||
        .pipe(chmod(644))
 | 
			
		||||
        .pipe(gulpif(enabled.chmod, chmod(644)))
 | 
			
		||||
        .pipe(gulp.dest(destination.js))
 | 
			
		||||
        .pipe(gulpif(argv.livereload, livereload()));
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
gulp.task('scripts_concat_markdown', function() {
 | 
			
		||||
    gulp.src('src/scripts/markdown/**/*.js')
 | 
			
		||||
        .pipe(gulpif(enabled.failCheck, plumber()))
 | 
			
		||||
        .pipe(gulpif(enabled.maps, sourcemaps.init()))
 | 
			
		||||
        .pipe(concat("markdown.min.js"))
 | 
			
		||||
        .pipe(gulpif(enabled.uglify, uglify()))
 | 
			
		||||
        .pipe(gulpif(enabled.maps, sourcemaps.write(".")))
 | 
			
		||||
        .pipe(chmod(644))
 | 
			
		||||
        .pipe(gulp.dest(destination.js))
 | 
			
		||||
        .pipe(gulpif(argv.livereload, livereload()));
 | 
			
		||||
 | 
			
		||||
/* Simply move these vendor scripts from node_modules. */
 | 
			
		||||
gulp.task('scripts_move_vendor', function(done) {
 | 
			
		||||
 | 
			
		||||
    let toMove = [
 | 
			
		||||
    'node_modules/video.js/dist/video.min.js',
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    gulp.src(toMove)
 | 
			
		||||
    .pipe(gulp.dest(destination.js + '/vendor/'));
 | 
			
		||||
    done();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -111,9 +131,9 @@ gulp.task('watch',function() {
 | 
			
		||||
    gulp.watch('src/templates/**/*.pug',['templates']);
 | 
			
		||||
    gulp.watch('src/scripts/*.js',['scripts']);
 | 
			
		||||
    gulp.watch('src/scripts/tutti/**/*.js',['scripts_concat_tutti']);
 | 
			
		||||
    gulp.watch('src/scripts/markdown/**/*.js',['scripts_concat_markdown']);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
// Erases all generated files in output directories.
 | 
			
		||||
gulp.task('cleanup', function() {
 | 
			
		||||
    var paths = [];
 | 
			
		||||
@@ -136,5 +156,5 @@ gulp.task('default', tasks.concat([
 | 
			
		||||
    'templates',
 | 
			
		||||
    'scripts',
 | 
			
		||||
    'scripts_concat_tutti',
 | 
			
		||||
    'scripts_concat_markdown',
 | 
			
		||||
    'scripts_move_vendor',
 | 
			
		||||
]));
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										5933
									
								
								package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										5933
									
								
								package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										54
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										54
									
								
								package.json
									
									
									
									
									
								
							@@ -1,26 +1,32 @@
 | 
			
		||||
{
 | 
			
		||||
	"name": "pillar",
 | 
			
		||||
	"license": "GPL-2.0+",
 | 
			
		||||
	"author": "Blender Institute",
 | 
			
		||||
	"repository": {
 | 
			
		||||
		"type": "git",
 | 
			
		||||
		"url": "https://github.com/armadillica/pillar.git"
 | 
			
		||||
	},
 | 
			
		||||
	"devDependencies": {
 | 
			
		||||
		"gulp": "~3.9.1",
 | 
			
		||||
		"gulp-autoprefixer": "~2.3.1",
 | 
			
		||||
		"gulp-cached": "~1.1.0",
 | 
			
		||||
		"gulp-chmod": "~1.3.0",
 | 
			
		||||
		"gulp-concat": "~2.6.0",
 | 
			
		||||
		"gulp-if": "^2.0.1",
 | 
			
		||||
		"gulp-git": "~2.4.2",
 | 
			
		||||
		"gulp-livereload": "~3.8.1",
 | 
			
		||||
		"gulp-plumber": "~1.1.0",
 | 
			
		||||
		"gulp-pug": "~3.2.0",
 | 
			
		||||
		"gulp-rename": "~1.2.2",
 | 
			
		||||
		"gulp-sass": "~2.3.1",
 | 
			
		||||
		"gulp-sourcemaps": "~1.6.0",
 | 
			
		||||
		"gulp-uglify": "~1.5.3",
 | 
			
		||||
		"minimist": "^1.2.0"
 | 
			
		||||
	}
 | 
			
		||||
  "name": "pillar",
 | 
			
		||||
  "license": "GPL-2.0+",
 | 
			
		||||
  "author": "Blender Institute",
 | 
			
		||||
  "repository": {
 | 
			
		||||
    "type": "git",
 | 
			
		||||
    "url": "git://git.blender.org/pillar.git"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "gulp": "^3.9.1",
 | 
			
		||||
    "gulp-autoprefixer": "^6.0.0",
 | 
			
		||||
    "gulp-cached": "^1.1.1",
 | 
			
		||||
    "gulp-chmod": "^2.0.0",
 | 
			
		||||
    "gulp-concat": "^2.6.1",
 | 
			
		||||
    "gulp-if": "^2.0.2",
 | 
			
		||||
    "gulp-git": "^2.8.0",
 | 
			
		||||
    "gulp-livereload": "^4.0.0",
 | 
			
		||||
    "gulp-plumber": "^1.2.0",
 | 
			
		||||
    "gulp-pug": "^4.0.1",
 | 
			
		||||
    "gulp-rename": "^1.4.0",
 | 
			
		||||
    "gulp-sass": "^4.0.1",
 | 
			
		||||
    "gulp-sourcemaps": "^2.6.4",
 | 
			
		||||
    "gulp-uglify-es": "^1.0.4",
 | 
			
		||||
    "minimist": "^1.2.0"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "bootstrap": "^4.1.3",
 | 
			
		||||
    "jquery": "^3.3.1",
 | 
			
		||||
    "popper.js": "^1.14.4",
 | 
			
		||||
    "video.js": "^7.2.2"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -140,8 +140,6 @@ class PillarServer(BlinkerCompatibleEve):
 | 
			
		||||
 | 
			
		||||
        self.org_manager = pillar.api.organizations.OrgManager()
 | 
			
		||||
 | 
			
		||||
        self.before_first_request(self.setup_db_indices)
 | 
			
		||||
 | 
			
		||||
        # Make CSRF protection available to the application. By default it is
 | 
			
		||||
        # disabled on all endpoints. More info at WTF_CSRF_CHECK_DEFAULT in config.py
 | 
			
		||||
        self.csrf = CSRFProtect(self)
 | 
			
		||||
@@ -184,7 +182,6 @@ class PillarServer(BlinkerCompatibleEve):
 | 
			
		||||
 | 
			
		||||
        if not self.config.get('STATIC_FILE_HASH'):
 | 
			
		||||
            self.log.warning('STATIC_FILE_HASH is empty, generating random one')
 | 
			
		||||
            f = open('/data/git/blender-cloud/config_local.py', 'a')
 | 
			
		||||
            h = re.sub(r'[_.~-]', '', secrets.token_urlsafe())[:8]
 | 
			
		||||
            self.config['STATIC_FILE_HASH'] = h
 | 
			
		||||
 | 
			
		||||
@@ -281,7 +278,7 @@ class PillarServer(BlinkerCompatibleEve):
 | 
			
		||||
        self.encoding_service_client = Zencoder(self.config['ZENCODER_API_KEY'])
 | 
			
		||||
 | 
			
		||||
    def _config_caching(self):
 | 
			
		||||
        from flask_cache import Cache
 | 
			
		||||
        from flask_caching import Cache
 | 
			
		||||
        self.cache = Cache(self)
 | 
			
		||||
 | 
			
		||||
    def set_languages(self, translations_folder: pathlib.Path):
 | 
			
		||||
@@ -480,10 +477,11 @@ class PillarServer(BlinkerCompatibleEve):
 | 
			
		||||
 | 
			
		||||
        # Pillar-defined Celery task modules:
 | 
			
		||||
        celery_task_modules = [
 | 
			
		||||
            'pillar.celery.tasks',
 | 
			
		||||
            'pillar.celery.search_index_tasks',
 | 
			
		||||
            'pillar.celery.file_link_tasks',
 | 
			
		||||
            'pillar.celery.badges',
 | 
			
		||||
            'pillar.celery.email_tasks',
 | 
			
		||||
            'pillar.celery.file_link_tasks',
 | 
			
		||||
            'pillar.celery.search_index_tasks',
 | 
			
		||||
            'pillar.celery.tasks',
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
        # Allow Pillar extensions from defining their own Celery tasks.
 | 
			
		||||
@@ -705,6 +703,8 @@ class PillarServer(BlinkerCompatibleEve):
 | 
			
		||||
    def finish_startup(self):
 | 
			
		||||
        self.log.info('Using MongoDB database %r', self.config['MONGO_DBNAME'])
 | 
			
		||||
 | 
			
		||||
        with self.app_context():
 | 
			
		||||
            self.setup_db_indices()
 | 
			
		||||
        self._config_celery()
 | 
			
		||||
 | 
			
		||||
        api.setup_app(self)
 | 
			
		||||
@@ -761,6 +761,8 @@ class PillarServer(BlinkerCompatibleEve):
 | 
			
		||||
        coll.create_index([('properties.status', pymongo.ASCENDING),
 | 
			
		||||
                           ('node_type', pymongo.ASCENDING),
 | 
			
		||||
                           ('_created', pymongo.DESCENDING)])
 | 
			
		||||
        # Used for asset tags
 | 
			
		||||
        coll.create_index([('properties.tags', pymongo.ASCENDING)])
 | 
			
		||||
 | 
			
		||||
        coll = db['projects']
 | 
			
		||||
        # This index is used for statistics, and for fetching public projects.
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,7 @@ with Blender ID.
 | 
			
		||||
 | 
			
		||||
import datetime
 | 
			
		||||
import logging
 | 
			
		||||
from urllib.parse import urljoin
 | 
			
		||||
 | 
			
		||||
import requests
 | 
			
		||||
from bson import tz_util
 | 
			
		||||
@@ -47,13 +48,6 @@ def store_subclient_token():
 | 
			
		||||
                    'subclient_user_id': str(db_user['_id'])}), status
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def blender_id_endpoint():
 | 
			
		||||
    """Gets the endpoint for the authentication API. If the env variable
 | 
			
		||||
    is defined, it's possible to override the (default) production address.
 | 
			
		||||
    """
 | 
			
		||||
    return current_app.config['BLENDER_ID_ENDPOINT'].rstrip('/')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def validate_create_user(blender_id_user_id, token, oauth_subclient_id):
 | 
			
		||||
    """Validates a user against Blender ID, creating the user in our database.
 | 
			
		||||
 | 
			
		||||
@@ -121,13 +115,14 @@ def validate_token(user_id, token, oauth_subclient_id):
 | 
			
		||||
        # We only want to accept Blender Cloud tokens.
 | 
			
		||||
        payload['client_id'] = current_app.config['OAUTH_CREDENTIALS']['blender-id']['id']
 | 
			
		||||
 | 
			
		||||
    url = '{0}/u/validate_token'.format(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)
 | 
			
		||||
 | 
			
		||||
    # Retry a few times when POSTing to BlenderID fails.
 | 
			
		||||
    # Source: http://stackoverflow.com/a/15431343/875379
 | 
			
		||||
    s = requests.Session()
 | 
			
		||||
    s.mount(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.
 | 
			
		||||
    try:
 | 
			
		||||
@@ -225,7 +220,7 @@ def fetch_blenderid_user() -> dict:
 | 
			
		||||
 | 
			
		||||
    my_log = log.getChild('fetch_blenderid_user')
 | 
			
		||||
 | 
			
		||||
    bid_url = '%s/api/user' % blender_id_endpoint()
 | 
			
		||||
    bid_url = urljoin(current_app.config['BLENDER_ID_ENDPOINT'], 'api/user')
 | 
			
		||||
    my_log.debug('Fetching user info from %s', bid_url)
 | 
			
		||||
 | 
			
		||||
    credentials = current_app.config['OAUTH_CREDENTIALS']['blender-id']
 | 
			
		||||
@@ -270,7 +265,7 @@ def setup_app(app, url_prefix):
 | 
			
		||||
def switch_user_url(next_url: str) -> str:
 | 
			
		||||
    from urllib.parse import quote
 | 
			
		||||
 | 
			
		||||
    base_url = '%s/switch' % blender_id_endpoint()
 | 
			
		||||
    base_url = urljoin(current_app.config['BLENDER_ID_ENDPOINT'], 'switch')
 | 
			
		||||
    if next_url:
 | 
			
		||||
        return '%s?next=%s' % (base_url, quote(next_url))
 | 
			
		||||
    return base_url
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,8 @@
 | 
			
		||||
import copy
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
from bson import ObjectId, tz_util
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
import cerberus.errors
 | 
			
		||||
from eve.io.mongo import Validator
 | 
			
		||||
from flask import current_app
 | 
			
		||||
 | 
			
		||||
@@ -12,6 +12,31 @@ log = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
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.
 | 
			
		||||
    def convert_properties(self, properties, node_schema):
 | 
			
		||||
        """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']
 | 
			
		||||
 | 
			
		||||
    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
 | 
			
		||||
 | 
			
		||||
        projects_collection = current_app.data.driver.db['projects']
 | 
			
		||||
@@ -107,7 +137,8 @@ class ValidateCustomFields(Validator):
 | 
			
		||||
        if val:
 | 
			
		||||
            # This ensures the modifications made by v's coercion rules are
 | 
			
		||||
            # 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
 | 
			
		||||
 | 
			
		||||
        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
 | 
			
		||||
        pre-insert hooks to set default values.
 | 
			
		||||
 | 
			
		||||
        The rule's arguments are validated against this schema:
 | 
			
		||||
        {'type': 'boolean'}
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        if not required_after_creation:
 | 
			
		||||
@@ -125,14 +159,14 @@ class ValidateCustomFields(Validator):
 | 
			
		||||
            # validator at all.
 | 
			
		||||
            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.
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        if not value:
 | 
			
		||||
            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.
 | 
			
		||||
 | 
			
		||||
        Supports both IPv6 and IPv4 ranges. Requires the IPy module.
 | 
			
		||||
@@ -149,40 +183,36 @@ class ValidateCustomFields(Validator):
 | 
			
		||||
        if ip.prefixlen() == 0:
 | 
			
		||||
            self._error(field_name, 'Zero-length prefix is not allowed')
 | 
			
		||||
 | 
			
		||||
    def _validate_type_binary(self, field_name: str, value: bytes):
 | 
			
		||||
        """Add support for binary type.
 | 
			
		||||
 | 
			
		||||
        This type was actually introduced in Cerberus 1.0, so we can drop
 | 
			
		||||
        support for this once Eve starts using that version (or newer).
 | 
			
		||||
    def _validator_markdown(self, field, value):
 | 
			
		||||
        """Convert MarkDown.
 | 
			
		||||
        """
 | 
			
		||||
        my_log = log.getChild('_validator_markdown')
 | 
			
		||||
 | 
			
		||||
        if not isinstance(value, (bytes, bytearray)):
 | 
			
		||||
            self._error(field_name, f'wrong value type {type(value)}, expected bytes or bytearray')
 | 
			
		||||
        # Find this field inside the original document
 | 
			
		||||
        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):
 | 
			
		||||
        """Override Cerberus' _validate_coerce method for richer features.
 | 
			
		||||
 | 
			
		||||
        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`.
 | 
			
		||||
        """
 | 
			
		||||
        my_log.debug('validating field %r with value %r', field, value)
 | 
			
		||||
        save_to = pillar.markdown.cache_field_name(field)
 | 
			
		||||
        html = pillar.markdown.markdown(value)
 | 
			
		||||
        field_name = pillar.markdown.cache_field_name(field)
 | 
			
		||||
        self.current[field_name] = html
 | 
			
		||||
        return value
 | 
			
		||||
        my_log.debug('saving result to %r in doc with id %s', save_to, id(my_subdoc))
 | 
			
		||||
        my_subdoc[save_to] = html
 | 
			
		||||
 | 
			
		||||
    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__':
 | 
			
		||||
@@ -190,12 +220,12 @@ if __name__ == '__main__':
 | 
			
		||||
 | 
			
		||||
    v = ValidateCustomFields()
 | 
			
		||||
    v.schema = {
 | 
			
		||||
        'foo': {'type': 'string', 'coerce': 'markdown'},
 | 
			
		||||
        'foo': {'type': 'string', 'validator': 'markdown'},
 | 
			
		||||
        'foo_html': {'type': 'string'},
 | 
			
		||||
        'nested': {
 | 
			
		||||
            'type': 'dict',
 | 
			
		||||
            'schema': {
 | 
			
		||||
                'bar': {'type': 'string', 'coerce': 'markdown'},
 | 
			
		||||
                'bar': {'type': 'string', 'validator': 'markdown'},
 | 
			
		||||
                'bar_html': {'type': 'string'},
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 
 | 
			
		||||
@@ -121,12 +121,43 @@ users_schema = {
 | 
			
		||||
    'service': {
 | 
			
		||||
        'type': 'dict',
 | 
			
		||||
        'allow_unknown': True,
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    # Node-specific information for this user.
 | 
			
		||||
    'nodes': {
 | 
			
		||||
        'type': 'dict',
 | 
			
		||||
        'schema': {
 | 
			
		||||
            'badger': {
 | 
			
		||||
                'type': 'list',
 | 
			
		||||
                'schema': {'type': 'string'}
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
            # Per watched video info about where the user left off, both in time and in percent.
 | 
			
		||||
            'view_progress': {
 | 
			
		||||
                'type': 'dict',
 | 
			
		||||
                # Keyed by Node ID of the video asset. MongoDB doesn't support using
 | 
			
		||||
                # ObjectIds as key, so we cast them to string instead.
 | 
			
		||||
                'keyschema': {'type': 'string'},
 | 
			
		||||
                'valueschema': {
 | 
			
		||||
                    'type': 'dict',
 | 
			
		||||
                    'schema': {
 | 
			
		||||
                        'progress_in_sec': {'type': 'float', 'min': 0},
 | 
			
		||||
                        'progress_in_percent': {'type': 'integer', 'min': 0, 'max': 100},
 | 
			
		||||
 | 
			
		||||
                        # When the progress was last updated, so we can limit this history to
 | 
			
		||||
                        # the last-watched N videos if we want, or show stuff in chrono order.
 | 
			
		||||
                        'last_watched': {'type': 'datetime'},
 | 
			
		||||
 | 
			
		||||
                        # True means progress_in_percent = 100, for easy querying
 | 
			
		||||
                        'done': {'type': 'boolean', 'default': False},
 | 
			
		||||
                    },
 | 
			
		||||
                },
 | 
			
		||||
            },
 | 
			
		||||
 | 
			
		||||
        },
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    'badges': {
 | 
			
		||||
        'type': 'dict',
 | 
			
		||||
        'schema': {
 | 
			
		||||
            'html': {'type': 'string'},  # HTML fetched from Blender ID.
 | 
			
		||||
            'expires': {'type': 'datetime'},  # When we should fetch it again.
 | 
			
		||||
        },
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    # Properties defined by extensions. Extensions should use their name (see the
 | 
			
		||||
@@ -155,7 +186,7 @@ organizations_schema = {
 | 
			
		||||
    'description': {
 | 
			
		||||
        'type': 'string',
 | 
			
		||||
        'maxlength': 256,
 | 
			
		||||
        'coerce': 'markdown',
 | 
			
		||||
        'validator': 'markdown',
 | 
			
		||||
    },
 | 
			
		||||
    '_description_html': {'type': 'string'},
 | 
			
		||||
    'website': {
 | 
			
		||||
@@ -227,7 +258,7 @@ organizations_schema = {
 | 
			
		||||
                'start': {'type': 'binary', 'required': True},
 | 
			
		||||
                'end': {'type': 'binary', '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': {
 | 
			
		||||
        'type': 'string',
 | 
			
		||||
        'coerce': 'markdown',
 | 
			
		||||
        'validator': 'markdown',
 | 
			
		||||
    },
 | 
			
		||||
    '_description_html': {'type': 'string'},
 | 
			
		||||
    'picture': _file_embedded_schema,
 | 
			
		||||
@@ -327,7 +358,7 @@ nodes_schema = {
 | 
			
		||||
    'properties': {
 | 
			
		||||
        'type': 'dict',
 | 
			
		||||
        'valid_properties': True,
 | 
			
		||||
        'required': True,
 | 
			
		||||
        'required': True
 | 
			
		||||
    },
 | 
			
		||||
    'permissions': {
 | 
			
		||||
        'type': 'dict',
 | 
			
		||||
@@ -345,11 +376,11 @@ tokens_schema = {
 | 
			
		||||
    },
 | 
			
		||||
    'token': {
 | 
			
		||||
        'type': 'string',
 | 
			
		||||
        'required': False,
 | 
			
		||||
        'required': True,
 | 
			
		||||
    },
 | 
			
		||||
    'token_hashed': {
 | 
			
		||||
        'type': 'string',
 | 
			
		||||
        'required': True,
 | 
			
		||||
        'required': False,
 | 
			
		||||
    },
 | 
			
		||||
    'expire_time': {
 | 
			
		||||
        'type': 'datetime',
 | 
			
		||||
@@ -368,6 +399,13 @@ tokens_schema = {
 | 
			
		||||
            'type': 'string',
 | 
			
		||||
        },
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    # OAuth scopes granted to this token.
 | 
			
		||||
    'oauth_scopes': {
 | 
			
		||||
        'type': 'list',
 | 
			
		||||
        'default': [],
 | 
			
		||||
        'schema': {'type': 'string'},
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
files_schema = {
 | 
			
		||||
@@ -539,7 +577,7 @@ projects_schema = {
 | 
			
		||||
    },
 | 
			
		||||
    'description': {
 | 
			
		||||
        'type': 'string',
 | 
			
		||||
        'coerce': 'markdown',
 | 
			
		||||
        'validator': 'markdown',
 | 
			
		||||
    },
 | 
			
		||||
    '_description_html': {'type': 'string'},
 | 
			
		||||
    # 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_ALLOW_CREDENTIALS = True
 | 
			
		||||
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.
 | 
			
		||||
    # We never have to b64decode the string anyway.
 | 
			
		||||
    token_bytes = prefix + base64.b64encode(random_bits, altchars=b'xy').strip(b'=')
 | 
			
		||||
    token = token_bytes.decode('ascii')
 | 
			
		||||
    token = prefix + base64.b64encode(random_bits, altchars=b'xy').strip(b'=')
 | 
			
		||||
 | 
			
		||||
    token_expiry = utcnow() + datetime.timedelta(days=days)
 | 
			
		||||
    token_data = store_token(user_id, token, token_expiry)
 | 
			
		||||
 | 
			
		||||
    # Include the token in the returned document so that it can be stored client-side,
 | 
			
		||||
    # in configuration, etc.
 | 
			
		||||
    token_data['token'] = token
 | 
			
		||||
 | 
			
		||||
    return token_data
 | 
			
		||||
    return store_token(user_id, token.decode('ascii'), token_expiry)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def hash_password(password: str, salt: typing.Union[str, bytes]) -> str:
 | 
			
		||||
 
 | 
			
		||||
@@ -12,7 +12,7 @@ ATTACHMENT_SLUG_REGEX = r'[a-zA-Z0-9_\-]+'
 | 
			
		||||
attachments_embedded_schema = {
 | 
			
		||||
    'type': 'dict',
 | 
			
		||||
    # TODO: will be renamed to 'keyschema' in Cerberus 1.0
 | 
			
		||||
    'propertyschema': {
 | 
			
		||||
    'keyschema': {
 | 
			
		||||
        'type': 'string',
 | 
			
		||||
        'regex': '^%s$' % ATTACHMENT_SLUG_REGEX,
 | 
			
		||||
    },
 | 
			
		||||
@@ -40,6 +40,51 @@ attachments_embedded_schema = {
 | 
			
		||||
    },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# TODO (fsiddi) reference this schema in all node_types that allow ratings
 | 
			
		||||
ratings_embedded_schema = {
 | 
			
		||||
    'type': 'dict',
 | 
			
		||||
    # Total count of positive ratings (updated at every rating action)
 | 
			
		||||
    'schema': {
 | 
			
		||||
        'positive': {
 | 
			
		||||
            'type': 'integer',
 | 
			
		||||
        },
 | 
			
		||||
        # Total count of negative ratings (updated at every rating action)
 | 
			
		||||
        'negative': {
 | 
			
		||||
            'type': 'integer',
 | 
			
		||||
        },
 | 
			
		||||
        # Collection of ratings, keyed by user
 | 
			
		||||
        'ratings': {
 | 
			
		||||
            'type': 'list',
 | 
			
		||||
            'schema': {
 | 
			
		||||
                'type': 'dict',
 | 
			
		||||
                'schema': {
 | 
			
		||||
                    'user': {
 | 
			
		||||
                        'type': 'objectid',
 | 
			
		||||
                        'data_relation': {
 | 
			
		||||
                            'resource': 'users',
 | 
			
		||||
                            'field': '_id',
 | 
			
		||||
                            'embeddable': False
 | 
			
		||||
                        }
 | 
			
		||||
                    },
 | 
			
		||||
                    'is_positive': {
 | 
			
		||||
                        'type': 'boolean'
 | 
			
		||||
                    },
 | 
			
		||||
                    # Weight of the rating based on user rep and the context.
 | 
			
		||||
                    # Currently we have the following weights:
 | 
			
		||||
                    # - 1 auto null
 | 
			
		||||
                    # - 2 manual null
 | 
			
		||||
                    # - 3 auto valid
 | 
			
		||||
                    # - 4 manual valid
 | 
			
		||||
                    'weight': {
 | 
			
		||||
                        'type': 'integer'
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        'hot': {'type': 'float'},
 | 
			
		||||
    },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Import after defining the common embedded schemas, to prevent dependency cycles.
 | 
			
		||||
from pillar.api.node_types.asset import node_type_asset
 | 
			
		||||
from pillar.api.node_types.blog import node_type_blog
 | 
			
		||||
 
 | 
			
		||||
@@ -7,7 +7,7 @@ node_type_comment = {
 | 
			
		||||
            'type': 'string',
 | 
			
		||||
            'minlength': 5,
 | 
			
		||||
            'required': True,
 | 
			
		||||
            'coerce': 'markdown',
 | 
			
		||||
            'validator': 'markdown',
 | 
			
		||||
        },
 | 
			
		||||
        '_content_html': {'type': 'string'},
 | 
			
		||||
        'status': {
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,7 @@ node_type_group = {
 | 
			
		||||
    'description': 'Folder node type',
 | 
			
		||||
    'parent': ['group', 'project'],
 | 
			
		||||
    'dyn_schema': {
 | 
			
		||||
        # Used for sorting within the context of a group
 | 
			
		||||
 | 
			
		||||
        'order': {
 | 
			
		||||
            'type': 'integer'
 | 
			
		||||
        },
 | 
			
		||||
@@ -20,7 +20,8 @@ node_type_group = {
 | 
			
		||||
        'notes': {
 | 
			
		||||
            'type': 'string',
 | 
			
		||||
            'maxlength': 256,
 | 
			
		||||
        },
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    },
 | 
			
		||||
    'form_schema': {
 | 
			
		||||
        'url': {'visible': False},
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ node_type_post = {
 | 
			
		||||
            'minlength': 5,
 | 
			
		||||
            'maxlength': 90000,
 | 
			
		||||
            'required': True,
 | 
			
		||||
            'coerce': 'markdown',
 | 
			
		||||
            'validator': 'markdown',
 | 
			
		||||
        },
 | 
			
		||||
        '_content_html': {'type': 'string'},
 | 
			
		||||
        'status': {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,16 +1,12 @@
 | 
			
		||||
import base64
 | 
			
		||||
import functools
 | 
			
		||||
import logging
 | 
			
		||||
import urllib.parse
 | 
			
		||||
 | 
			
		||||
import pymongo.errors
 | 
			
		||||
import werkzeug.exceptions as wz_exceptions
 | 
			
		||||
from bson import ObjectId
 | 
			
		||||
from flask import current_app, Blueprint, request
 | 
			
		||||
 | 
			
		||||
from pillar.api.activities import activity_subscribe, activity_object_add
 | 
			
		||||
from pillar.api.node_types import PILLAR_NAMED_NODE_TYPES
 | 
			
		||||
from pillar.api.file_storage_backends.gcs import update_file_name
 | 
			
		||||
from pillar.api.nodes import hooks
 | 
			
		||||
from pillar.api.nodes.hooks import short_link_info
 | 
			
		||||
from pillar.api.utils import str2id, jsonify
 | 
			
		||||
from pillar.api.utils.authorization import check_permissions, require_login
 | 
			
		||||
 | 
			
		||||
@@ -19,40 +15,6 @@ blueprint = Blueprint('nodes_api', __name__)
 | 
			
		||||
ROLES_FOR_SHARING = {'subscriber', 'demo'}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def only_for_node_type_decorator(*required_node_type_names):
 | 
			
		||||
    """Returns a decorator that checks its first argument's node type.
 | 
			
		||||
 | 
			
		||||
    If the node type is not of the required node type, returns None,
 | 
			
		||||
    otherwise calls the wrapped function.
 | 
			
		||||
 | 
			
		||||
    >>> deco = only_for_node_type_decorator('comment')
 | 
			
		||||
    >>> @deco
 | 
			
		||||
    ... def handle_comment(node): pass
 | 
			
		||||
 | 
			
		||||
    >>> deco = only_for_node_type_decorator('comment', 'post')
 | 
			
		||||
    >>> @deco
 | 
			
		||||
    ... def handle_comment_or_post(node): pass
 | 
			
		||||
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    # Convert to a set for efficient 'x in required_node_type_names' queries.
 | 
			
		||||
    required_node_type_names = set(required_node_type_names)
 | 
			
		||||
 | 
			
		||||
    def only_for_node_type(wrapped):
 | 
			
		||||
        @functools.wraps(wrapped)
 | 
			
		||||
        def wrapper(node, *args, **kwargs):
 | 
			
		||||
            if node.get('node_type') not in required_node_type_names:
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            return wrapped(node, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
        return wrapper
 | 
			
		||||
 | 
			
		||||
    only_for_node_type.__doc__ = "Decorator, immediately returns when " \
 | 
			
		||||
                                 "the first argument is not of type %s." % required_node_type_names
 | 
			
		||||
    return only_for_node_type
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@blueprint.route('/<node_id>/share', methods=['GET', 'POST'])
 | 
			
		||||
@require_login(require_roles=ROLES_FOR_SHARING)
 | 
			
		||||
def share_node(node_id):
 | 
			
		||||
@@ -88,6 +50,67 @@ def share_node(node_id):
 | 
			
		||||
    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):
 | 
			
		||||
    nodes_coll = current_app.data.driver.db['nodes']
 | 
			
		||||
    node_id = node['_id']
 | 
			
		||||
@@ -163,265 +186,35 @@ def create_short_code(node) -> str:
 | 
			
		||||
    return short_code
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def short_link_info(short_code):
 | 
			
		||||
    """Returns the short link info in a dict."""
 | 
			
		||||
 | 
			
		||||
    short_link = urllib.parse.urljoin(
 | 
			
		||||
        current_app.config['SHORT_LINK_BASE_URL'], short_code)
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        'short_code': short_code,
 | 
			
		||||
        'short_link': short_link,
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def before_replacing_node(item, original):
 | 
			
		||||
    check_permissions('nodes', original, 'PUT')
 | 
			
		||||
    update_file_name(item)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def after_replacing_node(item, original):
 | 
			
		||||
    """Push an update to the Algolia index when a node item is updated. If the
 | 
			
		||||
    project is private, prevent public indexing.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    from pillar.celery import search_index_tasks as index
 | 
			
		||||
 | 
			
		||||
    projects_collection = current_app.data.driver.db['projects']
 | 
			
		||||
    project = projects_collection.find_one({'_id': item['project']})
 | 
			
		||||
    if project.get('is_private', False):
 | 
			
		||||
        # Skip index updating and return
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    status = item['properties'].get('status', 'unpublished')
 | 
			
		||||
    node_id = str(item['_id'])
 | 
			
		||||
 | 
			
		||||
    if status == 'published':
 | 
			
		||||
        index.node_save.delay(node_id)
 | 
			
		||||
    else:
 | 
			
		||||
        index.node_delete.delay(node_id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def before_inserting_nodes(items):
 | 
			
		||||
    """Before inserting a node in the collection we check if the user is allowed
 | 
			
		||||
    and we append the project id to it.
 | 
			
		||||
    """
 | 
			
		||||
    from pillar.auth import current_user
 | 
			
		||||
 | 
			
		||||
    nodes_collection = current_app.data.driver.db['nodes']
 | 
			
		||||
 | 
			
		||||
    def find_parent_project(node):
 | 
			
		||||
        """Recursive function that finds the ultimate parent of a node."""
 | 
			
		||||
        if node and 'parent' in node:
 | 
			
		||||
            parent = nodes_collection.find_one({'_id': node['parent']})
 | 
			
		||||
            return find_parent_project(parent)
 | 
			
		||||
        if node:
 | 
			
		||||
            return node
 | 
			
		||||
        else:
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
    for item in items:
 | 
			
		||||
        check_permissions('nodes', item, 'POST')
 | 
			
		||||
        if 'parent' in item and 'project' not in item:
 | 
			
		||||
            parent = nodes_collection.find_one({'_id': item['parent']})
 | 
			
		||||
            project = find_parent_project(parent)
 | 
			
		||||
            if project:
 | 
			
		||||
                item['project'] = project['_id']
 | 
			
		||||
 | 
			
		||||
        # Default the 'user' property to the current user.
 | 
			
		||||
        item.setdefault('user', current_user.user_id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def after_inserting_nodes(items):
 | 
			
		||||
    for item in items:
 | 
			
		||||
        # Skip subscriptions for first level items (since the context is not a
 | 
			
		||||
        # node, but a project).
 | 
			
		||||
        # TODO: support should be added for mixed context
 | 
			
		||||
        if 'parent' not in item:
 | 
			
		||||
            return
 | 
			
		||||
        context_object_id = item['parent']
 | 
			
		||||
        if item['node_type'] == 'comment':
 | 
			
		||||
            nodes_collection = current_app.data.driver.db['nodes']
 | 
			
		||||
            parent = nodes_collection.find_one({'_id': item['parent']})
 | 
			
		||||
            # Always subscribe to the parent node
 | 
			
		||||
            activity_subscribe(item['user'], 'node', item['parent'])
 | 
			
		||||
            if parent['node_type'] == 'comment':
 | 
			
		||||
                # If the parent is a comment, we provide its own parent as
 | 
			
		||||
                # context. We do this in order to point the user to an asset
 | 
			
		||||
                # or group when viewing the notification.
 | 
			
		||||
                verb = 'replied'
 | 
			
		||||
                context_object_id = parent['parent']
 | 
			
		||||
                # Subscribe to the parent of the parent comment (post or group)
 | 
			
		||||
                activity_subscribe(item['user'], 'node', parent['parent'])
 | 
			
		||||
            else:
 | 
			
		||||
                activity_subscribe(item['user'], 'node', item['_id'])
 | 
			
		||||
                verb = 'commented'
 | 
			
		||||
        elif item['node_type'] in PILLAR_NAMED_NODE_TYPES:
 | 
			
		||||
            verb = 'posted'
 | 
			
		||||
            activity_subscribe(item['user'], 'node', item['_id'])
 | 
			
		||||
        else:
 | 
			
		||||
            # Don't automatically create activities for non-Pillar node types,
 | 
			
		||||
            # as we don't know what would be a suitable verb (among other things).
 | 
			
		||||
            continue
 | 
			
		||||
 | 
			
		||||
        activity_object_add(
 | 
			
		||||
            item['user'],
 | 
			
		||||
            verb,
 | 
			
		||||
            'node',
 | 
			
		||||
            item['_id'],
 | 
			
		||||
            'node',
 | 
			
		||||
            context_object_id
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def deduct_content_type(node_doc, original=None):
 | 
			
		||||
    """Deduct the content type from the attached file, if any."""
 | 
			
		||||
 | 
			
		||||
    if node_doc['node_type'] != 'asset':
 | 
			
		||||
        log.debug('deduct_content_type: called on node type %r, ignoring', node_doc['node_type'])
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    node_id = node_doc.get('_id')
 | 
			
		||||
    try:
 | 
			
		||||
        file_id = ObjectId(node_doc['properties']['file'])
 | 
			
		||||
    except KeyError:
 | 
			
		||||
        if node_id is None:
 | 
			
		||||
            # Creation of a file-less node is allowed, but updates aren't.
 | 
			
		||||
            return
 | 
			
		||||
        log.warning('deduct_content_type: Asset without properties.file, rejecting.')
 | 
			
		||||
        raise wz_exceptions.UnprocessableEntity('Missing file property for asset node')
 | 
			
		||||
 | 
			
		||||
    files = current_app.data.driver.db['files']
 | 
			
		||||
    file_doc = files.find_one({'_id': file_id},
 | 
			
		||||
                              {'content_type': 1})
 | 
			
		||||
    if not file_doc:
 | 
			
		||||
        log.warning('deduct_content_type: Node %s refers to non-existing file %s, rejecting.',
 | 
			
		||||
                    node_id, file_id)
 | 
			
		||||
        raise wz_exceptions.UnprocessableEntity('File property refers to non-existing file')
 | 
			
		||||
 | 
			
		||||
    # Guess the node content type from the file content type
 | 
			
		||||
    file_type = file_doc['content_type']
 | 
			
		||||
    if file_type.startswith('video/'):
 | 
			
		||||
        content_type = 'video'
 | 
			
		||||
    elif file_type.startswith('image/'):
 | 
			
		||||
        content_type = 'image'
 | 
			
		||||
    else:
 | 
			
		||||
        content_type = 'file'
 | 
			
		||||
 | 
			
		||||
    node_doc['properties']['content_type'] = content_type
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def nodes_deduct_content_type(nodes):
 | 
			
		||||
    for node in nodes:
 | 
			
		||||
        deduct_content_type(node)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def before_returning_node(node):
 | 
			
		||||
    # Run validation process, since GET on nodes entry point is public
 | 
			
		||||
    check_permissions('nodes', node, 'GET', append_allowed_methods=True)
 | 
			
		||||
 | 
			
		||||
    # Embed short_link_info if the node has a short_code.
 | 
			
		||||
    short_code = node.get('short_code')
 | 
			
		||||
    if short_code:
 | 
			
		||||
        node['short_link'] = short_link_info(short_code)['short_link']
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def before_returning_nodes(nodes):
 | 
			
		||||
    for node in nodes['_items']:
 | 
			
		||||
        before_returning_node(node)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def node_set_default_picture(node, original=None):
 | 
			
		||||
    """Uses the image of an image asset or colour map of texture node as picture."""
 | 
			
		||||
 | 
			
		||||
    if node.get('picture'):
 | 
			
		||||
        log.debug('Node %s already has a picture, not overriding', node.get('_id'))
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    node_type = node.get('node_type')
 | 
			
		||||
    props = node.get('properties', {})
 | 
			
		||||
    content = props.get('content_type')
 | 
			
		||||
 | 
			
		||||
    if node_type == 'asset' and content == 'image':
 | 
			
		||||
        image_file_id = props.get('file')
 | 
			
		||||
    elif node_type == 'texture':
 | 
			
		||||
        # Find the colour map, defaulting to the first image map available.
 | 
			
		||||
        image_file_id = None
 | 
			
		||||
        for image in props.get('files', []):
 | 
			
		||||
            if image_file_id is None or image.get('map_type') == 'color':
 | 
			
		||||
                image_file_id = image.get('file')
 | 
			
		||||
    else:
 | 
			
		||||
        log.debug('Not setting default picture on node type %s content type %s',
 | 
			
		||||
                  node_type, content)
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    if image_file_id is None:
 | 
			
		||||
        log.debug('Nothing to set the picture to.')
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    log.debug('Setting default picture for node %s to %s', node.get('_id'), image_file_id)
 | 
			
		||||
    node['picture'] = image_file_id
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def nodes_set_default_picture(nodes):
 | 
			
		||||
    for node in nodes:
 | 
			
		||||
        node_set_default_picture(node)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def before_deleting_node(node: dict):
 | 
			
		||||
    check_permissions('nodes', node, 'DELETE')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def after_deleting_node(item):
 | 
			
		||||
    from pillar.celery import search_index_tasks as index
 | 
			
		||||
    index.node_delete.delay(str(item['_id']))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
only_for_textures = only_for_node_type_decorator('texture')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@only_for_textures
 | 
			
		||||
def texture_sort_files(node, original=None):
 | 
			
		||||
    """Sort files alphabetically by map type, with colour map first."""
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        files = node['properties']['files']
 | 
			
		||||
    except KeyError:
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    # Sort the map types alphabetically, ensuring 'color' comes first.
 | 
			
		||||
    as_dict = {f['map_type']: f for f in files}
 | 
			
		||||
    types = sorted(as_dict.keys(), key=lambda k: '\0' if k == 'color' else k)
 | 
			
		||||
    node['properties']['files'] = [as_dict[map_type] for map_type in types]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def textures_sort_files(nodes):
 | 
			
		||||
    for node in nodes:
 | 
			
		||||
        texture_sort_files(node)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def setup_app(app, url_prefix):
 | 
			
		||||
    global _tagged
 | 
			
		||||
 | 
			
		||||
    cached = app.cache.memoize(timeout=300)
 | 
			
		||||
    _tagged = cached(_tagged)
 | 
			
		||||
 | 
			
		||||
    from . import patch
 | 
			
		||||
    patch.setup_app(app, url_prefix=url_prefix)
 | 
			
		||||
 | 
			
		||||
    app.on_fetched_item_nodes += before_returning_node
 | 
			
		||||
    app.on_fetched_resource_nodes += before_returning_nodes
 | 
			
		||||
    app.on_fetched_item_nodes += hooks.before_returning_node
 | 
			
		||||
    app.on_fetched_resource_nodes += hooks.before_returning_nodes
 | 
			
		||||
 | 
			
		||||
    app.on_replace_nodes += before_replacing_node
 | 
			
		||||
    app.on_replace_nodes += texture_sort_files
 | 
			
		||||
    app.on_replace_nodes += deduct_content_type
 | 
			
		||||
    app.on_replace_nodes += node_set_default_picture
 | 
			
		||||
    app.on_replaced_nodes += after_replacing_node
 | 
			
		||||
    app.on_replace_nodes += hooks.before_replacing_node
 | 
			
		||||
    app.on_replace_nodes += hooks.parse_markdown
 | 
			
		||||
    app.on_replace_nodes += hooks.texture_sort_files
 | 
			
		||||
    app.on_replace_nodes += hooks.deduct_content_type
 | 
			
		||||
    app.on_replace_nodes += hooks.node_set_default_picture
 | 
			
		||||
    app.on_replaced_nodes += hooks.after_replacing_node
 | 
			
		||||
 | 
			
		||||
    app.on_insert_nodes += before_inserting_nodes
 | 
			
		||||
    app.on_insert_nodes += nodes_deduct_content_type
 | 
			
		||||
    app.on_insert_nodes += nodes_set_default_picture
 | 
			
		||||
    app.on_insert_nodes += textures_sort_files
 | 
			
		||||
    app.on_inserted_nodes += after_inserting_nodes
 | 
			
		||||
    app.on_insert_nodes += hooks.before_inserting_nodes
 | 
			
		||||
    app.on_insert_nodes += hooks.parse_markdowns
 | 
			
		||||
    app.on_insert_nodes += hooks.nodes_deduct_content_type
 | 
			
		||||
    app.on_insert_nodes += hooks.nodes_set_default_picture
 | 
			
		||||
    app.on_insert_nodes += hooks.textures_sort_files
 | 
			
		||||
    app.on_inserted_nodes += hooks.after_inserting_nodes
 | 
			
		||||
 | 
			
		||||
    app.on_update_nodes += texture_sort_files
 | 
			
		||||
    app.on_update_nodes += hooks.texture_sort_files
 | 
			
		||||
 | 
			
		||||
    app.on_delete_item_nodes += before_deleting_node
 | 
			
		||||
    app.on_deleted_item_nodes += after_deleting_node
 | 
			
		||||
    app.on_delete_item_nodes += hooks.before_deleting_node
 | 
			
		||||
    app.on_deleted_item_nodes += hooks.after_deleting_node
 | 
			
		||||
 | 
			
		||||
    app.register_api_blueprint(blueprint, url_prefix=url_prefix)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										325
									
								
								pillar/api/nodes/hooks.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										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."""
 | 
			
		||||
 | 
			
		||||
import attr
 | 
			
		||||
import flask_pymongo.wrappers
 | 
			
		||||
import pymongo.database
 | 
			
		||||
from bson import ObjectId
 | 
			
		||||
 | 
			
		||||
from pillar import attrs_extra
 | 
			
		||||
@@ -10,7 +10,7 @@ import pillar.api.file_storage.moving
 | 
			
		||||
 | 
			
		||||
@attr.s
 | 
			
		||||
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))
 | 
			
		||||
    _log = attrs_extra.log('%s.NodeMover' % __name__)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -71,14 +71,19 @@ def before_delete_project(document):
 | 
			
		||||
 | 
			
		||||
def after_delete_project(project: dict):
 | 
			
		||||
    """Perform delete on the project's files too."""
 | 
			
		||||
 | 
			
		||||
    from werkzeug.exceptions import NotFound
 | 
			
		||||
    from eve.methods.delete import delete
 | 
			
		||||
 | 
			
		||||
    pid = project['_id']
 | 
			
		||||
    log.info('Project %s was deleted, also deleting its files.', pid)
 | 
			
		||||
 | 
			
		||||
    r, _, _, status = delete('files', {'project': pid})
 | 
			
		||||
    try:
 | 
			
		||||
        r, _, _, status = delete('files', {'project': pid})
 | 
			
		||||
    except NotFound:
 | 
			
		||||
        # There were no files, and that's fine.
 | 
			
		||||
        return
 | 
			
		||||
    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)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -142,7 +142,7 @@ def after_fetching_user(user):
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    # Remove all fields except public ones.
 | 
			
		||||
    public_fields = {'full_name', 'username', 'email', 'extension_props_public'}
 | 
			
		||||
    public_fields = {'full_name', 'username', 'email', 'extension_props_public', 'badges'}
 | 
			
		||||
    for field in list(user.keys()):
 | 
			
		||||
        if field not in public_fields:
 | 
			
		||||
            del user[field]
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,11 @@
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
from eve.methods.get import get
 | 
			
		||||
from flask import Blueprint
 | 
			
		||||
from flask import Blueprint, request
 | 
			
		||||
import werkzeug.exceptions as wz_exceptions
 | 
			
		||||
 | 
			
		||||
from pillar.api.utils import jsonify
 | 
			
		||||
from pillar import current_app
 | 
			
		||||
from pillar.api import utils
 | 
			
		||||
from pillar.api.utils.authorization import require_login
 | 
			
		||||
from pillar.auth import current_user
 | 
			
		||||
 | 
			
		||||
@@ -15,7 +17,128 @@ blueprint_api = Blueprint('users_api', __name__)
 | 
			
		||||
@require_login()
 | 
			
		||||
def my_info():
 | 
			
		||||
    eve_resp, _, _, status, _ = get('users', {'_id': current_user.user_id})
 | 
			
		||||
    resp = jsonify(eve_resp['_items'][0], status=status)
 | 
			
		||||
    resp = utils.jsonify(eve_resp['_items'][0], status=status)
 | 
			
		||||
    return resp
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@blueprint_api.route('/video/<video_id>/progress')
 | 
			
		||||
@require_login()
 | 
			
		||||
def get_video_progress(video_id: str):
 | 
			
		||||
    """Return video progress information.
 | 
			
		||||
 | 
			
		||||
    Either a `204 No Content` is returned (no information stored),
 | 
			
		||||
    or a `200 Ok` with JSON from Eve's 'users' schema, from the key
 | 
			
		||||
    video.view_progress.<video_id>.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    # Validation of the video ID; raises a BadRequest when it's not an ObjectID.
 | 
			
		||||
    # This isn't strictly necessary, but it makes this function behave symmetrical
 | 
			
		||||
    # to the set_video_progress() function.
 | 
			
		||||
    utils.str2id(video_id)
 | 
			
		||||
 | 
			
		||||
    users_coll = current_app.db('users')
 | 
			
		||||
    user_doc = users_coll.find_one(current_user.user_id, projection={'nodes.view_progress': True})
 | 
			
		||||
    try:
 | 
			
		||||
        progress = user_doc['nodes']['view_progress'][video_id]
 | 
			
		||||
    except KeyError:
 | 
			
		||||
        return '', 204
 | 
			
		||||
    if not progress:
 | 
			
		||||
        return '', 204
 | 
			
		||||
 | 
			
		||||
    return utils.jsonify(progress)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@blueprint_api.route('/video/<video_id>/progress', methods=['POST'])
 | 
			
		||||
@require_login()
 | 
			
		||||
def set_video_progress(video_id: str):
 | 
			
		||||
    """Save progress information about a certain video.
 | 
			
		||||
 | 
			
		||||
    Expected parameters:
 | 
			
		||||
    - progress_in_sec: float number of seconds
 | 
			
		||||
    - progress_in_perc: integer percentage of video watched (interval [0-100])
 | 
			
		||||
    """
 | 
			
		||||
    my_log = log.getChild('set_video_progress')
 | 
			
		||||
    my_log.debug('Setting video progress for user %r video %r', current_user.user_id, video_id)
 | 
			
		||||
 | 
			
		||||
    # Constructing this response requires an active app, and thus can't be done on module load.
 | 
			
		||||
    no_video_response = utils.jsonify({'_message': 'No such video'}, status=404)
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        progress_in_sec = float(request.form['progress_in_sec'])
 | 
			
		||||
        progress_in_perc = int(request.form['progress_in_perc'])
 | 
			
		||||
    except KeyError as ex:
 | 
			
		||||
        my_log.debug('Missing POST field in request: %s', ex)
 | 
			
		||||
        raise wz_exceptions.BadRequest(f'missing a form field')
 | 
			
		||||
    except ValueError as ex:
 | 
			
		||||
        my_log.debug('Invalid value for POST field in request: %s', ex)
 | 
			
		||||
        raise wz_exceptions.BadRequest(f'Invalid value for field: {ex}')
 | 
			
		||||
 | 
			
		||||
    users_coll = current_app.db('users')
 | 
			
		||||
    nodes_coll = current_app.db('nodes')
 | 
			
		||||
 | 
			
		||||
    # First check whether this is actually an existing video
 | 
			
		||||
    video_oid = utils.str2id(video_id)
 | 
			
		||||
    video_doc = nodes_coll.find_one(video_oid, projection={
 | 
			
		||||
        'node_type': True,
 | 
			
		||||
        'properties.content_type': True,
 | 
			
		||||
        'properties.file': True,
 | 
			
		||||
    })
 | 
			
		||||
    if not video_doc:
 | 
			
		||||
        my_log.debug('Node %r not found, unable to set progress for user %r',
 | 
			
		||||
                     video_oid, current_user.user_id)
 | 
			
		||||
        return no_video_response
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        is_video = (video_doc['node_type'] == 'asset'
 | 
			
		||||
                    and video_doc['properties']['content_type'] == 'video')
 | 
			
		||||
    except KeyError:
 | 
			
		||||
        is_video = False
 | 
			
		||||
 | 
			
		||||
    if not is_video:
 | 
			
		||||
        my_log.info('Node %r is not a video, unable to set progress for user %r',
 | 
			
		||||
                    video_oid, current_user.user_id)
 | 
			
		||||
        # There is no video found at this URL, so act as if it doesn't even exist.
 | 
			
		||||
        return no_video_response
 | 
			
		||||
 | 
			
		||||
    # Compute the progress
 | 
			
		||||
    percent = min(100, max(0, progress_in_perc))
 | 
			
		||||
    progress = {
 | 
			
		||||
        'progress_in_sec': progress_in_sec,
 | 
			
		||||
        'progress_in_percent': percent,
 | 
			
		||||
        'last_watched': utils.utcnow(),
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    # After watching a certain percentage of the video, we consider it 'done'
 | 
			
		||||
    #
 | 
			
		||||
    #                   Total     Credit start  Total  Credit  Percent
 | 
			
		||||
    #                   HH:MM:SS  HH:MM:SS      sec    sec     of duration
 | 
			
		||||
    # Sintel            00:14:48  00:12:24      888    744     83.78%
 | 
			
		||||
    # Tears of Steel    00:12:14  00:09:49      734    589     80.25%
 | 
			
		||||
    # Cosmos Laundro    00:12:10  00:10:05      730    605     82.88%
 | 
			
		||||
    # Agent 327         00:03:51  00:03:26      231    206     89.18%
 | 
			
		||||
    # Caminandes 3      00:02:30  00:02:18      150    138     92.00%
 | 
			
		||||
    # Glass Half        00:03:13  00:02:52      193    172     89.12%
 | 
			
		||||
    # Big Buck Bunny    00:09:56  00:08:11      596    491     82.38%
 | 
			
		||||
    # Elephant’s Drea   00:10:54  00:09:25      654    565     86.39%
 | 
			
		||||
    #
 | 
			
		||||
    #                                      Median              85.09%
 | 
			
		||||
    #                                      Average             85.75%
 | 
			
		||||
    #
 | 
			
		||||
    # For training videos marking at done at 85% of the video may be a bit
 | 
			
		||||
    # early, since those probably won't have (long) credits. This is why we
 | 
			
		||||
    # stick to 90% here.
 | 
			
		||||
    if percent >= 90:
 | 
			
		||||
        progress['done'] = True
 | 
			
		||||
 | 
			
		||||
    # Setting each property individually prevents us from overwriting any
 | 
			
		||||
    # existing {done: true} fields.
 | 
			
		||||
    updates = {f'nodes.view_progress.{video_id}.{k}': v
 | 
			
		||||
               for k, v in progress.items()}
 | 
			
		||||
    result = users_coll.update_one({'_id': current_user.user_id},
 | 
			
		||||
                                   {'$set': updates})
 | 
			
		||||
 | 
			
		||||
    if result.matched_count == 0:
 | 
			
		||||
        my_log.error('Current user %r could not be updated', current_user.user_id)
 | 
			
		||||
        raise wz_exceptions.InternalServerError('Unable to find logged-in user')
 | 
			
		||||
 | 
			
		||||
    return '', 204
 | 
			
		||||
 
 | 
			
		||||
@@ -245,4 +245,10 @@ def random_etag() -> str:
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def utcnow() -> datetime.datetime:
 | 
			
		||||
    return datetime.datetime.now(tz=bson.tz_util.utc)
 | 
			
		||||
    """Construct timezone-aware 'now' in UTC with millisecond precision."""
 | 
			
		||||
    now = datetime.datetime.now(tz=bson.tz_util.utc)
 | 
			
		||||
 | 
			
		||||
    # MongoDB stores in millisecond precision, so truncate the microseconds.
 | 
			
		||||
    # This way the returned datetime can be round-tripped via MongoDB and stay the same.
 | 
			
		||||
    trunc_now = now.replace(microsecond=now.microsecond - (now.microsecond % 1000))
 | 
			
		||||
    return trunc_now
 | 
			
		||||
 
 | 
			
		||||
@@ -13,7 +13,7 @@ import logging
 | 
			
		||||
import typing
 | 
			
		||||
 | 
			
		||||
import bson
 | 
			
		||||
from flask import g, current_app
 | 
			
		||||
from flask import g, current_app, session
 | 
			
		||||
from flask import request
 | 
			
		||||
from werkzeug import exceptions as wz_exceptions
 | 
			
		||||
 | 
			
		||||
@@ -103,7 +103,7 @@ def find_user_in_db(user_info: dict, provider='blender-id') -> dict:
 | 
			
		||||
    return db_user
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def validate_token(*, force=False):
 | 
			
		||||
def validate_token(*, force=False) -> bool:
 | 
			
		||||
    """Validate the token provided in the request and populate the current_user
 | 
			
		||||
    flask.g object, so that permissions and access to a resource can be defined
 | 
			
		||||
    from it.
 | 
			
		||||
@@ -115,7 +115,7 @@ def validate_token(*, force=False):
 | 
			
		||||
    :returns: True iff the user is logged in with a valid Blender ID token.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    from pillar.auth import AnonymousUser
 | 
			
		||||
    import pillar.auth
 | 
			
		||||
 | 
			
		||||
    # Trust a pre-existing g.current_user
 | 
			
		||||
    if not force:
 | 
			
		||||
@@ -133,16 +133,22 @@ def validate_token(*, force=False):
 | 
			
		||||
        oauth_subclient = ''
 | 
			
		||||
    else:
 | 
			
		||||
        # Check the session, the user might be logged in through Flask-Login.
 | 
			
		||||
        from pillar import auth
 | 
			
		||||
 | 
			
		||||
        token = auth.get_blender_id_oauth_token()
 | 
			
		||||
        # The user has a logged-in session; trust only if this request passes a CSRF check.
 | 
			
		||||
        # FIXME(Sybren): we should stop saving the token as 'user_id' in the sesion.
 | 
			
		||||
        token = session.get('user_id')
 | 
			
		||||
        if token:
 | 
			
		||||
            log.debug('skipping token check because current user already has a session')
 | 
			
		||||
            current_app.csrf.protect()
 | 
			
		||||
        else:
 | 
			
		||||
            token = pillar.auth.get_blender_id_oauth_token()
 | 
			
		||||
        oauth_subclient = None
 | 
			
		||||
 | 
			
		||||
    if not token:
 | 
			
		||||
        # If no authorization headers are provided, we are getting a request
 | 
			
		||||
        # from a non logged in user. Proceed accordingly.
 | 
			
		||||
        log.debug('No authentication headers, so not logged in.')
 | 
			
		||||
        g.current_user = AnonymousUser()
 | 
			
		||||
        g.current_user = pillar.auth.AnonymousUser()
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
    return validate_this_token(token, oauth_subclient) is not None
 | 
			
		||||
@@ -194,7 +200,7 @@ def remove_token(token: str):
 | 
			
		||||
    tokens_coll = current_app.db('tokens')
 | 
			
		||||
    token_hashed = hash_auth_token(token)
 | 
			
		||||
 | 
			
		||||
    # TODO: remove matching on unhashed tokens once all tokens have been hashed.
 | 
			
		||||
    # TODO: remove matching on hashed tokens once all hashed tokens have expired.
 | 
			
		||||
    lookup = {'$or': [{'token': token}, {'token_hashed': token_hashed}]}
 | 
			
		||||
    del_res = tokens_coll.delete_many(lookup)
 | 
			
		||||
    log.debug('Removed token %r, matched %d documents', token, del_res.deleted_count)
 | 
			
		||||
@@ -206,7 +212,7 @@ def find_token(token, is_subclient_token=False, **extra_filters):
 | 
			
		||||
    tokens_coll = current_app.db('tokens')
 | 
			
		||||
    token_hashed = hash_auth_token(token)
 | 
			
		||||
 | 
			
		||||
    # TODO: remove matching on unhashed tokens once all tokens have been hashed.
 | 
			
		||||
    # TODO: remove matching on hashed tokens once all hashed tokens have expired.
 | 
			
		||||
    lookup = {'$or': [{'token': token}, {'token_hashed': token_hashed}],
 | 
			
		||||
              'is_subclient_token': True if is_subclient_token else {'$in': [False, None]},
 | 
			
		||||
              'expire_time': {"$gt": utcnow()}}
 | 
			
		||||
@@ -229,8 +235,14 @@ def hash_auth_token(token: str) -> str:
 | 
			
		||||
    return base64.b64encode(digest).decode('ascii')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def store_token(user_id, token: str, token_expiry, oauth_subclient_id=False,
 | 
			
		||||
                org_roles: typing.Set[str] = frozenset()):
 | 
			
		||||
def store_token(user_id,
 | 
			
		||||
                token: str,
 | 
			
		||||
                token_expiry,
 | 
			
		||||
                oauth_subclient_id=False,
 | 
			
		||||
                *,
 | 
			
		||||
                org_roles: typing.Set[str] = frozenset(),
 | 
			
		||||
                oauth_scopes: typing.Optional[typing.List[str]] = None,
 | 
			
		||||
                ):
 | 
			
		||||
    """Stores an authentication token.
 | 
			
		||||
 | 
			
		||||
    :returns: the token document from MongoDB
 | 
			
		||||
@@ -240,13 +252,15 @@ def store_token(user_id, token: str, token_expiry, oauth_subclient_id=False,
 | 
			
		||||
 | 
			
		||||
    token_data = {
 | 
			
		||||
        'user': user_id,
 | 
			
		||||
        'token_hashed': hash_auth_token(token),
 | 
			
		||||
        'token': token,
 | 
			
		||||
        'expire_time': token_expiry,
 | 
			
		||||
    }
 | 
			
		||||
    if oauth_subclient_id:
 | 
			
		||||
        token_data['is_subclient_token'] = True
 | 
			
		||||
    if org_roles:
 | 
			
		||||
        token_data['org_roles'] = sorted(org_roles)
 | 
			
		||||
    if oauth_scopes:
 | 
			
		||||
        token_data['oauth_scopes'] = oauth_scopes
 | 
			
		||||
 | 
			
		||||
    r, _, _, status = current_app.post_internal('tokens', token_data)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
import logging
 | 
			
		||||
import functools
 | 
			
		||||
import typing
 | 
			
		||||
 | 
			
		||||
from bson import ObjectId
 | 
			
		||||
from flask import g
 | 
			
		||||
@@ -12,8 +13,9 @@ CHECK_PERMISSIONS_IMPLEMENTED_FOR = {'projects', 'nodes', 'flamenco_jobs'}
 | 
			
		||||
log = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def check_permissions(collection_name, resource, method, append_allowed_methods=False,
 | 
			
		||||
                      check_node_type=None):
 | 
			
		||||
def check_permissions(collection_name: str, resource: dict, method: str,
 | 
			
		||||
                      append_allowed_methods=False,
 | 
			
		||||
                      check_node_type: typing.Optional[str] = None):
 | 
			
		||||
    """Check user permissions to access a node. We look up node permissions from
 | 
			
		||||
    world to groups to users and match them with the computed user permissions.
 | 
			
		||||
    If there is not match, we raise 403.
 | 
			
		||||
@@ -93,8 +95,9 @@ def compute_allowed_methods(collection_name, resource, check_node_type=None):
 | 
			
		||||
    return allowed_methods
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def has_permissions(collection_name, resource, method, append_allowed_methods=False,
 | 
			
		||||
                    check_node_type=None):
 | 
			
		||||
def has_permissions(collection_name: str, resource: dict, method: str,
 | 
			
		||||
                    append_allowed_methods=False,
 | 
			
		||||
                    check_node_type: typing.Optional[str] = None):
 | 
			
		||||
    """Check user permissions to access a node. We look up node permissions from
 | 
			
		||||
    world to groups to users and match them with the computed user permissions.
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										87
									
								
								pillar/api/utils/rating.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								pillar/api/utils/rating.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,87 @@
 | 
			
		||||
# These functions come from Reddit
 | 
			
		||||
# https://github.com/reddit/reddit/blob/master/r2/r2/lib/db/_sorts.pyx
 | 
			
		||||
 | 
			
		||||
# Additional resources
 | 
			
		||||
# http://www.redditblog.com/2009/10/reddits-new-comment-sorting-system.html
 | 
			
		||||
# http://www.evanmiller.org/how-not-to-sort-by-average-rating.html
 | 
			
		||||
# http://amix.dk/blog/post/19588
 | 
			
		||||
 | 
			
		||||
from datetime import datetime, timezone
 | 
			
		||||
from math import log
 | 
			
		||||
from math import sqrt
 | 
			
		||||
 | 
			
		||||
epoch = datetime(1970, 1, 1, 0, 0, 0, 0, timezone.utc)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def epoch_seconds(date):
 | 
			
		||||
    """Returns the number of seconds from the epoch to date."""
 | 
			
		||||
    td = date - epoch
 | 
			
		||||
    return td.days * 86400 + td.seconds + (float(td.microseconds) / 1000000)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def score(ups, downs):
 | 
			
		||||
    return ups - downs
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def hot(ups, downs, date):
 | 
			
		||||
    """The hot formula. Reddit's hot ranking uses the logarithm function to
 | 
			
		||||
    weight the first votes higher than the rest.
 | 
			
		||||
    The first 10 upvotes have the same weight as the next 100 upvotes which
 | 
			
		||||
    have the same weight as the next 1000, etc.
 | 
			
		||||
 | 
			
		||||
    Dillo authors: we modified the formula to give more weight to negative
 | 
			
		||||
    votes when an entry is controversial.
 | 
			
		||||
 | 
			
		||||
    TODO: make this function more dynamic so that different defaults can be
 | 
			
		||||
    specified depending on the item that is being rated.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    s = score(ups, downs)
 | 
			
		||||
    order = log(max(abs(s), 1), 10)
 | 
			
		||||
    sign = 1 if s > 0 else -1 if s < 0 else 0
 | 
			
		||||
    seconds = epoch_seconds(date) - 1134028003
 | 
			
		||||
    base_hot = round(sign * order + seconds / 45000, 7)
 | 
			
		||||
 | 
			
		||||
    if downs > 1:
 | 
			
		||||
        rating_delta = 100 * (downs - ups) / downs
 | 
			
		||||
        if rating_delta < 25:
 | 
			
		||||
            # The post is controversial
 | 
			
		||||
            return base_hot
 | 
			
		||||
        base_hot = base_hot - (downs * 6)
 | 
			
		||||
 | 
			
		||||
    return base_hot
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _confidence(ups, downs):
 | 
			
		||||
    n = ups + downs
 | 
			
		||||
 | 
			
		||||
    if n == 0:
 | 
			
		||||
        return 0
 | 
			
		||||
 | 
			
		||||
    z = 1.0 #1.0 = 85%, 1.6 = 95%
 | 
			
		||||
    phat = float(ups) / n
 | 
			
		||||
    return sqrt(phat+z*z/(2*n)-z*((phat*(1-phat)+z*z/(4*n))/n))/(1+z*z/n)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def confidence(ups, downs):
 | 
			
		||||
    if ups + downs == 0:
 | 
			
		||||
        return 0
 | 
			
		||||
    else:
 | 
			
		||||
        return _confidence(ups, downs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def update_hot(document):
 | 
			
		||||
    """Update the hotness of a document given its current ratings.
 | 
			
		||||
 | 
			
		||||
    We expect the document to implement the ratings_embedded_schema in
 | 
			
		||||
    a 'ratings' property.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    dt = document['_created']
 | 
			
		||||
    dt = dt.replace(tzinfo=timezone.utc)
 | 
			
		||||
 | 
			
		||||
    document['properties']['ratings']['hot'] = hot(
 | 
			
		||||
        document['properties']['ratings']['positive'],
 | 
			
		||||
        document['properties']['ratings']['negative'],
 | 
			
		||||
        dt,
 | 
			
		||||
    )
 | 
			
		||||
@@ -38,6 +38,8 @@ class UserClass(flask_login.UserMixin):
 | 
			
		||||
        self.groups: typing.List[str] = []  # NOTE: these are stringified object IDs.
 | 
			
		||||
        self.group_ids: typing.List[bson.ObjectId] = []
 | 
			
		||||
        self.capabilities: typing.Set[str] = set()
 | 
			
		||||
        self.nodes: dict = {}  # see the 'nodes' key in eve_settings.py::user_schema.
 | 
			
		||||
        self.badges_html: str = ''
 | 
			
		||||
 | 
			
		||||
        # Lazily evaluated
 | 
			
		||||
        self._has_organizations: typing.Optional[bool] = None
 | 
			
		||||
@@ -56,6 +58,12 @@ class UserClass(flask_login.UserMixin):
 | 
			
		||||
        user.email = db_user.get('email') or ''
 | 
			
		||||
        user.username = db_user.get('username') or ''
 | 
			
		||||
        user.full_name = db_user.get('full_name') or ''
 | 
			
		||||
        user.badges_html = db_user.get('badges', {}).get('html') or ''
 | 
			
		||||
 | 
			
		||||
        # Be a little more specific than just db_user['nodes']
 | 
			
		||||
        user.nodes = {
 | 
			
		||||
            'view_progress': db_user.get('nodes', {}).get('view_progress', {}),
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        # Derived properties
 | 
			
		||||
        user.objectid = str(user.user_id or '')
 | 
			
		||||
@@ -210,6 +218,11 @@ def login_user(oauth_token: str, *, load_from_db=False):
 | 
			
		||||
        user = _load_user(oauth_token)
 | 
			
		||||
    else:
 | 
			
		||||
        user = UserClass(oauth_token)
 | 
			
		||||
    login_user_object(user)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def login_user_object(user: UserClass):
 | 
			
		||||
    """Log in the given user."""
 | 
			
		||||
    flask_login.login_user(user, remember=True)
 | 
			
		||||
    g.current_user = user
 | 
			
		||||
    user_authenticated.send(None)
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,9 @@
 | 
			
		||||
import abc
 | 
			
		||||
import attr
 | 
			
		||||
import json
 | 
			
		||||
import logging
 | 
			
		||||
import typing
 | 
			
		||||
 | 
			
		||||
import attr
 | 
			
		||||
from rauth import OAuth2Service
 | 
			
		||||
from flask import current_app, url_for, request, redirect, session, Response
 | 
			
		||||
 | 
			
		||||
@@ -15,6 +16,8 @@ class OAuthUserResponse:
 | 
			
		||||
 | 
			
		||||
    id = attr.ib(validator=attr.validators.instance_of(str))
 | 
			
		||||
    email = attr.ib(validator=attr.validators.instance_of(str))
 | 
			
		||||
    access_token = attr.ib(validator=attr.validators.instance_of(str))
 | 
			
		||||
    scopes: typing.List[str] = attr.ib(validator=attr.validators.instance_of(list))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class OAuthError(Exception):
 | 
			
		||||
@@ -127,25 +130,26 @@ class OAuthSignIn(metaclass=abc.ABCMeta):
 | 
			
		||||
 | 
			
		||||
class BlenderIdSignIn(OAuthSignIn):
 | 
			
		||||
    provider_name = 'blender-id'
 | 
			
		||||
    scopes = ['email', 'badge']
 | 
			
		||||
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        from urllib.parse import urljoin
 | 
			
		||||
        super().__init__()
 | 
			
		||||
 | 
			
		||||
        base_url = current_app.config['OAUTH_CREDENTIALS']['blender-id'].get(
 | 
			
		||||
            'base_url', 'https://www.blender.org/id/')
 | 
			
		||||
        base_url = current_app.config['BLENDER_ID_ENDPOINT']
 | 
			
		||||
 | 
			
		||||
        self.service = OAuth2Service(
 | 
			
		||||
            name='blender-id',
 | 
			
		||||
            client_id=self.consumer_id,
 | 
			
		||||
            client_secret=self.consumer_secret,
 | 
			
		||||
            authorize_url='%soauth/authorize' % base_url,
 | 
			
		||||
            access_token_url='%soauth/token' % base_url,
 | 
			
		||||
            base_url='%sapi/' % base_url
 | 
			
		||||
            authorize_url=urljoin(base_url, 'oauth/authorize'),
 | 
			
		||||
            access_token_url=urljoin(base_url, 'oauth/token'),
 | 
			
		||||
            base_url=urljoin(base_url, 'api/'),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def authorize(self):
 | 
			
		||||
        return redirect(self.service.get_authorize_url(
 | 
			
		||||
            scope='email',
 | 
			
		||||
            scope=' '.join(self.scopes),
 | 
			
		||||
            response_type='code',
 | 
			
		||||
            redirect_uri=self.get_callback_url())
 | 
			
		||||
        )
 | 
			
		||||
@@ -159,7 +163,11 @@ class BlenderIdSignIn(OAuthSignIn):
 | 
			
		||||
 | 
			
		||||
        session['blender_id_oauth_token'] = access_token
 | 
			
		||||
        me = oauth_session.get('user').json()
 | 
			
		||||
        return OAuthUserResponse(str(me['id']), me['email'])
 | 
			
		||||
 | 
			
		||||
        # Blender ID doesn't tell us which scopes were granted by the user, so
 | 
			
		||||
        # for now assume we got all the scopes we requested.
 | 
			
		||||
        # (see https://github.com/jazzband/django-oauth-toolkit/issues/644)
 | 
			
		||||
        return OAuthUserResponse(str(me['id']), me['email'], access_token, self.scopes)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class FacebookSignIn(OAuthSignIn):
 | 
			
		||||
@@ -189,7 +197,7 @@ class FacebookSignIn(OAuthSignIn):
 | 
			
		||||
        me = oauth_session.get('me?fields=id,email').json()
 | 
			
		||||
        # TODO handle case when user chooses not to disclose en email
 | 
			
		||||
        # see https://developers.facebook.com/docs/graph-api/reference/user/
 | 
			
		||||
        return OAuthUserResponse(me['id'], me.get('email'))
 | 
			
		||||
        return OAuthUserResponse(me['id'], me.get('email'), '', [])
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GoogleSignIn(OAuthSignIn):
 | 
			
		||||
@@ -217,4 +225,4 @@ class GoogleSignIn(OAuthSignIn):
 | 
			
		||||
        oauth_session = self.make_oauth_session()
 | 
			
		||||
 | 
			
		||||
        me = oauth_session.get('userinfo').json()
 | 
			
		||||
        return OAuthUserResponse(str(me['id']), me['email'])
 | 
			
		||||
        return OAuthUserResponse(str(me['id']), me['email'], '', [])
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										183
									
								
								pillar/badge_sync.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										183
									
								
								pillar/badge_sync.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,183 @@
 | 
			
		||||
import collections
 | 
			
		||||
import datetime
 | 
			
		||||
import logging
 | 
			
		||||
import typing
 | 
			
		||||
from urllib.parse import urljoin
 | 
			
		||||
 | 
			
		||||
import bson
 | 
			
		||||
import requests
 | 
			
		||||
 | 
			
		||||
from pillar import current_app
 | 
			
		||||
from pillar.api.utils import utcnow
 | 
			
		||||
 | 
			
		||||
SyncUser = collections.namedtuple('SyncUser', 'user_id token bid_user_id')
 | 
			
		||||
BadgeHTML = collections.namedtuple('BadgeHTML', 'html expires')
 | 
			
		||||
log = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class StopRefreshing(Exception):
 | 
			
		||||
    """Indicates that Blender ID is having problems.
 | 
			
		||||
 | 
			
		||||
    Further badge refreshes should be put on hold to avoid bludgeoning
 | 
			
		||||
    a suffering Blender ID.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def find_users_to_sync() -> typing.Iterable[SyncUser]:
 | 
			
		||||
    """Return user information of syncable users with badges."""
 | 
			
		||||
 | 
			
		||||
    now = utcnow()
 | 
			
		||||
    tokens_coll = current_app.db('tokens')
 | 
			
		||||
    cursor = tokens_coll.aggregate([
 | 
			
		||||
        # Find all users who have a 'badge' scope in their OAuth token.
 | 
			
		||||
        {'$match': {
 | 
			
		||||
            'token': {'$exists': True},
 | 
			
		||||
            'oauth_scopes': 'badge',
 | 
			
		||||
            'expire_time': {'$gt': now},
 | 
			
		||||
        }},
 | 
			
		||||
        {'$lookup': {
 | 
			
		||||
            'from': 'users',
 | 
			
		||||
            'localField': 'user',
 | 
			
		||||
            'foreignField': '_id',
 | 
			
		||||
            'as': 'user'
 | 
			
		||||
        }},
 | 
			
		||||
 | 
			
		||||
        # Prevent 'user' from being an array.
 | 
			
		||||
        {'$unwind': {'path': '$user'}},
 | 
			
		||||
 | 
			
		||||
        # Get the Blender ID user ID only.
 | 
			
		||||
        {'$unwind': {'path': '$user.auth'}},
 | 
			
		||||
        {'$match': {'user.auth.provider': 'blender-id'}},
 | 
			
		||||
 | 
			
		||||
        # Only select those users whose badge doesn't exist or has expired.
 | 
			
		||||
        {'$match': {
 | 
			
		||||
            'user.badges.expires': {'$not': {'$gt': now}}
 | 
			
		||||
        }},
 | 
			
		||||
 | 
			
		||||
        # Make sure that the badges that expire last are also refreshed last.
 | 
			
		||||
        {'$sort': {'user.badges.expires': 1}},
 | 
			
		||||
 | 
			
		||||
        # Reduce the document to the info we're after.
 | 
			
		||||
        {'$project': {
 | 
			
		||||
            'token': True,
 | 
			
		||||
            'user._id': True,
 | 
			
		||||
            'user.auth.user_id': True,
 | 
			
		||||
            'user.badges.expires': True,
 | 
			
		||||
        }},
 | 
			
		||||
    ])
 | 
			
		||||
 | 
			
		||||
    log.debug('Aggregating tokens and users')
 | 
			
		||||
    for user_info in cursor:
 | 
			
		||||
        log.debug('User %s has badges %s',
 | 
			
		||||
                  user_info['user']['_id'], user_info['user'].get('badges'))
 | 
			
		||||
        yield SyncUser(
 | 
			
		||||
            user_id=user_info['user']['_id'],
 | 
			
		||||
            token=user_info['token'],
 | 
			
		||||
            bid_user_id=user_info['user']['auth']['user_id'])
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def fetch_badge_html(session: requests.Session, user: SyncUser, size: str) \
 | 
			
		||||
        -> str:
 | 
			
		||||
    """Fetch a Blender ID badge for this user.
 | 
			
		||||
 | 
			
		||||
    :param session:
 | 
			
		||||
    :param user:
 | 
			
		||||
    :param size: Size indication for the badge images, see the Blender ID
 | 
			
		||||
        documentation/code. As of this writing valid sizes are {'s', 'm', 'l'}.
 | 
			
		||||
    """
 | 
			
		||||
    my_log = log.getChild('fetch_badge_html')
 | 
			
		||||
 | 
			
		||||
    blender_id_endpoint = current_app.config['BLENDER_ID_ENDPOINT']
 | 
			
		||||
    url = urljoin(blender_id_endpoint, f'api/badges/{user.bid_user_id}/html/{size}')
 | 
			
		||||
 | 
			
		||||
    my_log.debug('Fetching badge HTML at %s for user %s', url, user.user_id)
 | 
			
		||||
    try:
 | 
			
		||||
        resp = session.get(url, headers={'Authorization': f'Bearer {user.token}'})
 | 
			
		||||
    except requests.ConnectionError as ex:
 | 
			
		||||
        my_log.warning('Unable to connect to Blender ID at %s: %s', url, ex)
 | 
			
		||||
        raise StopRefreshing()
 | 
			
		||||
 | 
			
		||||
    if resp.status_code == 204:
 | 
			
		||||
        my_log.debug('No badges for user %s', user.user_id)
 | 
			
		||||
        return ''
 | 
			
		||||
    if resp.status_code == 403:
 | 
			
		||||
        my_log.warning('Tried fetching %s for user %s but received a 403: %s',
 | 
			
		||||
                       url, user.user_id, resp.text)
 | 
			
		||||
        return ''
 | 
			
		||||
    if resp.status_code == 400:
 | 
			
		||||
        my_log.warning('Blender ID did not accept our GET request at %s for user %s: %s',
 | 
			
		||||
                       url, user.user_id, resp.text)
 | 
			
		||||
        return ''
 | 
			
		||||
    if resp.status_code == 500:
 | 
			
		||||
        my_log.warning('Blender ID returned an internal server error on %s for user %s, '
 | 
			
		||||
                       'aborting all badge refreshes: %s', url, user.user_id, resp.text)
 | 
			
		||||
        raise StopRefreshing()
 | 
			
		||||
    if resp.status_code == 404:
 | 
			
		||||
        my_log.warning('Blender ID has no user %s for our user %s', user.bid_user_id, user.user_id)
 | 
			
		||||
        return ''
 | 
			
		||||
    resp.raise_for_status()
 | 
			
		||||
    return resp.text
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def refresh_all_badges(only_user_id: typing.Optional[bson.ObjectId] = None, *,
 | 
			
		||||
                       dry_run=False,
 | 
			
		||||
                       timelimit: datetime.timedelta):
 | 
			
		||||
    """Re-fetch all badges for all users, except when already refreshed recently.
 | 
			
		||||
 | 
			
		||||
    :param only_user_id: Only refresh this user. This is expected to be used
 | 
			
		||||
        sparingly during manual maintenance / debugging sessions only. It does
 | 
			
		||||
        fetch all users to refresh, and in Python code skips all except the
 | 
			
		||||
        given one.
 | 
			
		||||
    :param dry_run: if True the changes are described in the log, but not performed.
 | 
			
		||||
    :param timelimit: Refreshing will stop after this time. This allows for cron(-like)
 | 
			
		||||
        jobs to run without overlapping, even when the number fo badges to refresh
 | 
			
		||||
        becomes larger than possible within the period of the cron job.
 | 
			
		||||
    """
 | 
			
		||||
    from requests.adapters import HTTPAdapter
 | 
			
		||||
    my_log = log.getChild('fetch_badge_html')
 | 
			
		||||
 | 
			
		||||
    # Test the config before we start looping over the world.
 | 
			
		||||
    badge_expiry = badge_expiry_config()
 | 
			
		||||
    if not badge_expiry or not isinstance(badge_expiry, datetime.timedelta):
 | 
			
		||||
        raise ValueError('BLENDER_ID_BADGE_EXPIRY not configured properly, should be a timedelta')
 | 
			
		||||
 | 
			
		||||
    session = requests.Session()
 | 
			
		||||
    session.mount('https://', HTTPAdapter(max_retries=5))
 | 
			
		||||
    users_coll = current_app.db('users')
 | 
			
		||||
 | 
			
		||||
    deadline = utcnow() + timelimit
 | 
			
		||||
 | 
			
		||||
    num_updates = 0
 | 
			
		||||
    for user_info in find_users_to_sync():
 | 
			
		||||
        if utcnow() > deadline:
 | 
			
		||||
            my_log.info('Stopping badge refresh because the timelimit %s (H:MM:SS) was hit.',
 | 
			
		||||
                        timelimit)
 | 
			
		||||
            break
 | 
			
		||||
 | 
			
		||||
        if only_user_id and user_info.user_id != only_user_id:
 | 
			
		||||
            my_log.debug('Skipping user %s', user_info.user_id)
 | 
			
		||||
            continue
 | 
			
		||||
        try:
 | 
			
		||||
            badge_html = fetch_badge_html(session, user_info, 's')
 | 
			
		||||
        except StopRefreshing:
 | 
			
		||||
            my_log.error('Blender ID has internal problems, stopping badge refreshing at user %s',
 | 
			
		||||
                         user_info)
 | 
			
		||||
            break
 | 
			
		||||
 | 
			
		||||
        update = {'badges': {
 | 
			
		||||
            'html': badge_html,
 | 
			
		||||
            'expires': utcnow() + badge_expiry,
 | 
			
		||||
        }}
 | 
			
		||||
        num_updates += 1
 | 
			
		||||
        my_log.info('Updating badges HTML for Blender ID %s, user %s',
 | 
			
		||||
                    user_info.bid_user_id, user_info.user_id)
 | 
			
		||||
        if not dry_run:
 | 
			
		||||
            result = users_coll.update_one({'_id': user_info.user_id},
 | 
			
		||||
                                           {'$set': update})
 | 
			
		||||
            if result.matched_count != 1:
 | 
			
		||||
                my_log.warning('Unable to update badges for user %s', user_info.user_id)
 | 
			
		||||
    my_log.info('Updated badges of %d users%s', num_updates, ' (dry-run)' if dry_run else '')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def badge_expiry_config() -> datetime.timedelta:
 | 
			
		||||
    return current_app.config.get('BLENDER_ID_BADGE_EXPIRY')
 | 
			
		||||
							
								
								
									
										20
									
								
								pillar/celery/badges.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								pillar/celery/badges.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
"""Badge HTML synchronisation.
 | 
			
		||||
 | 
			
		||||
Note that this module can only be imported when an application context is
 | 
			
		||||
active. Best to late-import this in the functions where it's needed.
 | 
			
		||||
"""
 | 
			
		||||
import datetime
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
from pillar import current_app, badge_sync
 | 
			
		||||
 | 
			
		||||
log = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@current_app.celery.task(ignore_result=True)
 | 
			
		||||
def sync_badges_for_users(timelimit_seconds: int):
 | 
			
		||||
    """Synchronises Blender ID badges for the most-urgent users."""
 | 
			
		||||
 | 
			
		||||
    timelimit = datetime.timedelta(seconds=timelimit_seconds)
 | 
			
		||||
    log.info('Refreshing badges, timelimit is %s (H:MM:SS)', timelimit)
 | 
			
		||||
    badge_sync.refresh_all_badges(timelimit=timelimit)
 | 
			
		||||
@@ -13,6 +13,7 @@ from pillar.cli.maintenance import manager_maintenance
 | 
			
		||||
from pillar.cli.operations import manager_operations
 | 
			
		||||
from pillar.cli.setup import manager_setup
 | 
			
		||||
from pillar.cli.elastic import manager_elastic
 | 
			
		||||
from . import badges
 | 
			
		||||
 | 
			
		||||
from pillar.cli import translations
 | 
			
		||||
 | 
			
		||||
@@ -24,3 +25,4 @@ manager.add_command("maintenance", manager_maintenance)
 | 
			
		||||
manager.add_command("setup", manager_setup)
 | 
			
		||||
manager.add_command("operations", manager_operations)
 | 
			
		||||
manager.add_command("elastic", manager_elastic)
 | 
			
		||||
manager.add_command("badges", badges.manager)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										39
									
								
								pillar/cli/badges.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								pillar/cli/badges.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,39 @@
 | 
			
		||||
import datetime
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
from flask_script import Manager
 | 
			
		||||
from pillar import current_app, badge_sync
 | 
			
		||||
from pillar.api.utils import utcnow
 | 
			
		||||
 | 
			
		||||
log = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
manager = Manager(current_app, usage="Badge operations")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@manager.option('-u', '--user', dest='email', default='', help='Email address of the user to sync')
 | 
			
		||||
@manager.option('-a', '--all', dest='sync_all', action='store_true', default=False,
 | 
			
		||||
                help='Sync all users')
 | 
			
		||||
@manager.option('--go', action='store_true', default=False,
 | 
			
		||||
                help='Actually perform the sync; otherwise it is a dry-run.')
 | 
			
		||||
def sync(email: str = '', sync_all: bool=False, go: bool=False):
 | 
			
		||||
    if bool(email) == bool(sync_all):
 | 
			
		||||
        raise ValueError('Use either --user or --all.')
 | 
			
		||||
 | 
			
		||||
    if email:
 | 
			
		||||
        users_coll = current_app.db('users')
 | 
			
		||||
        db_user = users_coll.find_one({'email': email}, projection={'_id': True})
 | 
			
		||||
        if not db_user:
 | 
			
		||||
            raise ValueError(f'No user with email {email!r} found')
 | 
			
		||||
        specific_user = db_user['_id']
 | 
			
		||||
    else:
 | 
			
		||||
        specific_user = None
 | 
			
		||||
 | 
			
		||||
    if not go:
 | 
			
		||||
        log.info('Performing dry-run, not going to change the user database.')
 | 
			
		||||
    start_time = utcnow()
 | 
			
		||||
    badge_sync.refresh_all_badges(specific_user, dry_run=not go,
 | 
			
		||||
                                  timelimit=datetime.timedelta(hours=1))
 | 
			
		||||
    end_time = utcnow()
 | 
			
		||||
    log.info('%s took %s (H:MM:SS)',
 | 
			
		||||
             'Updating user badges' if go else 'Dry-run',
 | 
			
		||||
             end_time - start_time)
 | 
			
		||||
@@ -684,8 +684,8 @@ def upgrade_attachment_schema(proj_url=None, all_projects=False, go=False):
 | 
			
		||||
                log_proj()
 | 
			
		||||
                log.info('Removed %d empty attachment dicts', res.modified_count)
 | 
			
		||||
        else:
 | 
			
		||||
            to_remove = nodes_coll.count({'properties.attachments': {},
 | 
			
		||||
                                          'project': project['_id']})
 | 
			
		||||
            to_remove = nodes_coll.count_documents({'properties.attachments': {},
 | 
			
		||||
                                                    'project': project['_id']})
 | 
			
		||||
            if to_remove:
 | 
			
		||||
                log_proj()
 | 
			
		||||
                log.info('Would remove %d empty attachment dicts', to_remove)
 | 
			
		||||
@@ -767,7 +767,9 @@ def iter_markdown(proj_node_types: dict, some_node: dict, callback: typing.Calla
 | 
			
		||||
                    continue
 | 
			
		||||
                to_visit.append((subdoc, definition['schema']))
 | 
			
		||||
                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
 | 
			
		||||
 | 
			
		||||
            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
 | 
			
		||||
from os import getenv
 | 
			
		||||
from collections import defaultdict
 | 
			
		||||
 | 
			
		||||
import requests.certs
 | 
			
		||||
 | 
			
		||||
# Certificate file for communication with other systems.
 | 
			
		||||
@@ -29,10 +31,11 @@ DEBUG = False
 | 
			
		||||
SECRET_KEY = ''
 | 
			
		||||
 | 
			
		||||
# Authentication token hashing key. If empty falls back to UTF8-encoded SECRET_KEY with a warning.
 | 
			
		||||
# Not used to hash new tokens, but it is used to check pre-existing hashed tokens.
 | 
			
		||||
AUTH_TOKEN_HMAC_KEY = b''
 | 
			
		||||
 | 
			
		||||
# Authentication settings
 | 
			
		||||
BLENDER_ID_ENDPOINT = 'http://blender-id:8000/'
 | 
			
		||||
BLENDER_ID_ENDPOINT = 'http://id.local:8000/'
 | 
			
		||||
 | 
			
		||||
CDN_USE_URL_SIGNING = True
 | 
			
		||||
CDN_SERVICE_DOMAIN_PROTOCOL = 'https'
 | 
			
		||||
@@ -124,9 +127,8 @@ BLENDER_ID_USER_INFO_TOKEN = '-set-in-config-local-'
 | 
			
		||||
# Example entry:
 | 
			
		||||
# OAUTH_CREDENTIALS = {
 | 
			
		||||
#    'blender-id': {
 | 
			
		||||
#        'id': 'CLOUD-OF-SNOWFLAKES-43',
 | 
			
		||||
#        'id': 'CLOUD-OF-SNOWFLAKES-42',
 | 
			
		||||
#        'secret': 'thesecret',
 | 
			
		||||
#        'base_url': 'http://blender-id:8000/'
 | 
			
		||||
#     }
 | 
			
		||||
# }
 | 
			
		||||
# OAuth providers are defined in pillar.auth.oauth
 | 
			
		||||
@@ -204,8 +206,18 @@ CELERY_BEAT_SCHEDULE = {
 | 
			
		||||
        'schedule': 600,  # every N seconds
 | 
			
		||||
        'args': ('gcs', 100)
 | 
			
		||||
    },
 | 
			
		||||
    'refresh-blenderid-badges': {
 | 
			
		||||
        'task': 'pillar.celery.badges.sync_badges_for_users',
 | 
			
		||||
        'schedule': 600,  # every N seconds
 | 
			
		||||
        'args': (540, ),  # time limit in seconds, keep shorter than 'schedule'
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Badges will be re-fetched every timedelta.
 | 
			
		||||
# TODO(Sybren): A proper value should be determined after we actually have users with badges.
 | 
			
		||||
BLENDER_ID_BADGE_EXPIRY = datetime.timedelta(hours=4)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Mapping from user role to capabilities obtained by users with that role.
 | 
			
		||||
USER_CAPABILITIES = defaultdict(**{
 | 
			
		||||
    'subscriber': {'subscriber', 'home-project'},
 | 
			
		||||
 
 | 
			
		||||
@@ -45,11 +45,15 @@ ALLOWED_STYLES = [
 | 
			
		||||
def markdown(s: str) -> str:
 | 
			
		||||
    commented_shortcodes = shortcodes.comment_shortcodes(s)
 | 
			
		||||
    tainted_html = CommonMark.commonmark(commented_shortcodes)
 | 
			
		||||
    safe_html = bleach.clean(tainted_html,
 | 
			
		||||
                             tags=ALLOWED_TAGS,
 | 
			
		||||
 | 
			
		||||
    # Create a Cleaner that supports parsing of bare links (see filters).
 | 
			
		||||
    cleaner = bleach.Cleaner(tags=ALLOWED_TAGS,
 | 
			
		||||
                             attributes=ALLOWED_ATTRIBUTES,
 | 
			
		||||
                             styles=ALLOWED_STYLES,
 | 
			
		||||
                             strip_comments=False)
 | 
			
		||||
                             strip_comments=False,
 | 
			
		||||
                             filters=[bleach.linkifier.LinkifyFilter])
 | 
			
		||||
 | 
			
		||||
    safe_html = cleaner.clean(tainted_html)
 | 
			
		||||
    return safe_html
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,5 @@
 | 
			
		||||
import flask
 | 
			
		||||
import raven.breadcrumbs
 | 
			
		||||
from raven.contrib.flask import Sentry
 | 
			
		||||
 | 
			
		||||
from .auth import current_user
 | 
			
		||||
@@ -14,16 +16,14 @@ class PillarSentry(Sentry):
 | 
			
		||||
    def init_app(self, app, *args, **kwargs):
 | 
			
		||||
        super().init_app(app, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
        # We perform authentication of the user while handling the request,
 | 
			
		||||
        # so Sentry calls get_user_info() too early.
 | 
			
		||||
        flask.request_started.connect(self.__add_sentry_breadcrumbs, self)
 | 
			
		||||
 | 
			
		||||
    def get_user_context_again(self, ):
 | 
			
		||||
        from flask import request
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            self.client.user_context(self.get_user_info(request))
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            self.client.logger.exception(str(e))
 | 
			
		||||
    def __add_sentry_breadcrumbs(self, sender, **extra):
 | 
			
		||||
        raven.breadcrumbs.record(
 | 
			
		||||
            message='Request started',
 | 
			
		||||
            category='http',
 | 
			
		||||
            data={'url': flask.request.url}
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def get_user_info(self, request):
 | 
			
		||||
        user_info = super().get_user_info(request)
 | 
			
		||||
 
 | 
			
		||||
@@ -33,18 +33,57 @@ log = logging.getLogger(__name__)
 | 
			
		||||
def shortcode(name: str):
 | 
			
		||||
    """Class decorator for shortcodes."""
 | 
			
		||||
 | 
			
		||||
    def decorator(cls):
 | 
			
		||||
        assert hasattr(cls, '__call__'), '@shortcode should be used on callables.'
 | 
			
		||||
        if isinstance(cls, type):
 | 
			
		||||
            instance = cls()
 | 
			
		||||
    def decorator(decorated):
 | 
			
		||||
        assert hasattr(decorated, '__call__'), '@shortcode should be used on callables.'
 | 
			
		||||
        if isinstance(decorated, type):
 | 
			
		||||
            as_callable = decorated()
 | 
			
		||||
        else:
 | 
			
		||||
            instance = cls
 | 
			
		||||
        shortcodes.register(name)(instance)
 | 
			
		||||
        return cls
 | 
			
		||||
            as_callable = decorated
 | 
			
		||||
        shortcodes.register(name)(as_callable)
 | 
			
		||||
        return decorated
 | 
			
		||||
 | 
			
		||||
    return decorator
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class capcheck:
 | 
			
		||||
    """Decorator for shortcodes.
 | 
			
		||||
 | 
			
		||||
    On call, check for capabilities before calling the function. If the user does not
 | 
			
		||||
    have a capability, display a message insdead of the content.
 | 
			
		||||
 | 
			
		||||
    kwargs:
 | 
			
		||||
        - 'cap': Capability required for viewing.
 | 
			
		||||
        - 'nocap': Optional, text shown when the user does not have this capability.
 | 
			
		||||
        - others: Passed to the decorated shortcode.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def __init__(self, decorated):
 | 
			
		||||
        assert hasattr(decorated, '__call__'), '@capcheck should be used on callables.'
 | 
			
		||||
        if isinstance(decorated, type):
 | 
			
		||||
            as_callable = decorated()
 | 
			
		||||
        else:
 | 
			
		||||
            as_callable = decorated
 | 
			
		||||
        self.decorated = as_callable
 | 
			
		||||
 | 
			
		||||
    def __call__(self,
 | 
			
		||||
                 context: typing.Any,
 | 
			
		||||
                 content: str,
 | 
			
		||||
                 pargs: typing.List[str],
 | 
			
		||||
                 kwargs: typing.Dict[str, str]) -> str:
 | 
			
		||||
        from pillar.auth import current_user
 | 
			
		||||
 | 
			
		||||
        cap = kwargs.pop('cap', '')
 | 
			
		||||
        if cap:
 | 
			
		||||
            nocap = kwargs.pop('nocap', '')
 | 
			
		||||
            if not current_user.has_cap(cap):
 | 
			
		||||
                if not nocap:
 | 
			
		||||
                    return ''
 | 
			
		||||
                html = html_module.escape(nocap)
 | 
			
		||||
                return f'<p class="shortcode nocap">{html}</p>'
 | 
			
		||||
 | 
			
		||||
        return self.decorated(context, content, pargs, kwargs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@shortcode('test')
 | 
			
		||||
class Test:
 | 
			
		||||
    def __call__(self,
 | 
			
		||||
@@ -68,6 +107,7 @@ class Test:
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@shortcode('youtube')
 | 
			
		||||
@capcheck
 | 
			
		||||
class YouTube:
 | 
			
		||||
    log = log.getChild('YouTube')
 | 
			
		||||
 | 
			
		||||
@@ -122,13 +162,17 @@ class YouTube:
 | 
			
		||||
        if not youtube_id:
 | 
			
		||||
            return html_module.escape('{youtube invalid YouTube ID/URL}')
 | 
			
		||||
 | 
			
		||||
        src = f'https://www.youtube.com/embed/{youtube_id}?rel=0'
 | 
			
		||||
        html = f'<iframe class="shortcode youtube" width="{width}" height="{height}" src="{src}"' \
 | 
			
		||||
               f' frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>'
 | 
			
		||||
        src  = f'https://www.youtube.com/embed/{youtube_id}?rel=0'
 | 
			
		||||
        html = f'<div class="embed-responsive embed-responsive-16by9">' \
 | 
			
		||||
               f'<iframe class="shortcode youtube embed-responsive-item"' \
 | 
			
		||||
               f' width="{width}" height="{height}" src="{src}"' \
 | 
			
		||||
               f' frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>' \
 | 
			
		||||
               f'</div>'
 | 
			
		||||
        return html
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@shortcode('iframe')
 | 
			
		||||
@capcheck
 | 
			
		||||
def iframe(context: typing.Any,
 | 
			
		||||
           content: str,
 | 
			
		||||
           pargs: typing.List[str],
 | 
			
		||||
@@ -140,16 +184,6 @@ def iframe(context: typing.Any,
 | 
			
		||||
        - others: Turned into attributes for the iframe element.
 | 
			
		||||
    """
 | 
			
		||||
    import xml.etree.ElementTree as ET
 | 
			
		||||
    from pillar.auth import current_user
 | 
			
		||||
 | 
			
		||||
    cap = kwargs.pop('cap', '')
 | 
			
		||||
    if cap:
 | 
			
		||||
        nocap = kwargs.pop('nocap', '')
 | 
			
		||||
        if not current_user.has_cap(cap):
 | 
			
		||||
            if not nocap:
 | 
			
		||||
                return ''
 | 
			
		||||
            html = html_module.escape(nocap)
 | 
			
		||||
            return f'<p class="shortcode nocap">{html}</p>'
 | 
			
		||||
 | 
			
		||||
    kwargs['class'] = f'shortcode {kwargs.get("class", "")}'.strip()
 | 
			
		||||
    element = ET.Element('iframe', kwargs)
 | 
			
		||||
@@ -194,12 +228,25 @@ class Attachment:
 | 
			
		||||
 | 
			
		||||
        return self.render(file_doc, pargs, kwargs)
 | 
			
		||||
 | 
			
		||||
    def sdk_file(self, slug: str, node_properties: dict) -> pillarsdk.File:
 | 
			
		||||
    def sdk_file(self, slug: str, document: dict) -> pillarsdk.File:
 | 
			
		||||
        """Return the file document for the attachment with this slug."""
 | 
			
		||||
 | 
			
		||||
        from pillar.web import system_util
 | 
			
		||||
 | 
			
		||||
        attachments = node_properties.get('attachments', {})
 | 
			
		||||
        # TODO (fsiddi) Make explicit what 'document' is.
 | 
			
		||||
        # In some cases we pass the entire node or project documents, in other cases
 | 
			
		||||
        # we pass node.properties. This should be unified at the level of do_markdown.
 | 
			
		||||
        # For now we do a quick hack and first look for 'properties' in the doc,
 | 
			
		||||
        # then we look for 'attachments'.
 | 
			
		||||
 | 
			
		||||
        doc_properties = document.get('properties')
 | 
			
		||||
        if doc_properties:
 | 
			
		||||
            # We passed an entire document (all nodes must have 'properties')
 | 
			
		||||
            attachments = doc_properties.get('attachments', {})
 | 
			
		||||
        else:
 | 
			
		||||
            # The value of document could have been defined as 'node.properties'
 | 
			
		||||
            attachments = document.get('attachments', {})
 | 
			
		||||
 | 
			
		||||
        attachment = attachments.get(slug)
 | 
			
		||||
        if not attachment:
 | 
			
		||||
            raise self.NoSuchSlug(slug)
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
# -*- encoding: utf-8 -*-
 | 
			
		||||
 | 
			
		||||
import base64
 | 
			
		||||
import contextlib
 | 
			
		||||
import copy
 | 
			
		||||
import datetime
 | 
			
		||||
import json
 | 
			
		||||
@@ -10,11 +11,7 @@ import pathlib
 | 
			
		||||
import sys
 | 
			
		||||
import typing
 | 
			
		||||
import unittest.mock
 | 
			
		||||
 | 
			
		||||
try:
 | 
			
		||||
    from urllib.parse import urlencode
 | 
			
		||||
except ImportError:
 | 
			
		||||
    from urllib.parse import urlencode
 | 
			
		||||
from urllib.parse import urlencode, urljoin
 | 
			
		||||
 | 
			
		||||
from bson import ObjectId, tz_util
 | 
			
		||||
 | 
			
		||||
@@ -27,6 +24,7 @@ from eve.tests import TestMinimal
 | 
			
		||||
import pymongo.collection
 | 
			
		||||
from flask.testing import FlaskClient
 | 
			
		||||
import flask.ctx
 | 
			
		||||
import flask.wrappers
 | 
			
		||||
import responses
 | 
			
		||||
 | 
			
		||||
import pillar
 | 
			
		||||
@@ -185,7 +183,7 @@ class AbstractPillarTest(TestMinimal):
 | 
			
		||||
        else:
 | 
			
		||||
            self.ensure_project_exists()
 | 
			
		||||
 | 
			
		||||
        with self.app.test_request_context():
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            files_collection = self.app.data.driver.db['files']
 | 
			
		||||
            assert isinstance(files_collection, pymongo.collection.Collection)
 | 
			
		||||
 | 
			
		||||
@@ -326,15 +324,46 @@ class AbstractPillarTest(TestMinimal):
 | 
			
		||||
 | 
			
		||||
        return user
 | 
			
		||||
 | 
			
		||||
    def create_valid_auth_token(self, user_id, token='token'):
 | 
			
		||||
    @contextlib.contextmanager
 | 
			
		||||
    def login_as(self, user_id: typing.Union[str, ObjectId]):
 | 
			
		||||
        """Context manager, within the context the app context is active and the user logged in.
 | 
			
		||||
 | 
			
		||||
        The logging-in happens when a request starts, so it's only active when
 | 
			
		||||
        e.g. self.get() or self.post() or somesuch request is used.
 | 
			
		||||
        """
 | 
			
		||||
        from pillar.auth import UserClass, login_user_object
 | 
			
		||||
 | 
			
		||||
        if isinstance(user_id, str):
 | 
			
		||||
            user_oid = ObjectId(user_id)
 | 
			
		||||
        elif isinstance(user_id, ObjectId):
 | 
			
		||||
            user_oid = user_id
 | 
			
		||||
        else:
 | 
			
		||||
            raise TypeError(f'invalid type {type(user_id)} for parameter user_id')
 | 
			
		||||
        user_doc = self.fetch_user_from_db(user_oid)
 | 
			
		||||
 | 
			
		||||
        def signal_handler(sender, **kwargs):
 | 
			
		||||
            login_user_object(user)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            user = UserClass.construct('', user_doc)
 | 
			
		||||
            with flask.request_started.connected_to(signal_handler, self.app):
 | 
			
		||||
                yield
 | 
			
		||||
 | 
			
		||||
    # TODO: rename to 'create_auth_token' now that 'expire_in_days' can be negative.
 | 
			
		||||
    def create_valid_auth_token(self,
 | 
			
		||||
                                user_id: ObjectId,
 | 
			
		||||
                                token='token',
 | 
			
		||||
                                *,
 | 
			
		||||
                                oauth_scopes: typing.Optional[typing.List[str]]=None,
 | 
			
		||||
                                expire_in_days=1) -> dict:
 | 
			
		||||
        from pillar.api.utils import utcnow
 | 
			
		||||
 | 
			
		||||
        future = utcnow() + datetime.timedelta(days=1)
 | 
			
		||||
        future = utcnow() + datetime.timedelta(days=expire_in_days)
 | 
			
		||||
 | 
			
		||||
        with self.app.test_request_context():
 | 
			
		||||
            from pillar.api.utils import authentication as auth
 | 
			
		||||
 | 
			
		||||
            token_data = auth.store_token(user_id, token, future, None)
 | 
			
		||||
            token_data = auth.store_token(user_id, token, future, oauth_scopes=oauth_scopes)
 | 
			
		||||
 | 
			
		||||
        return token_data
 | 
			
		||||
 | 
			
		||||
@@ -364,7 +393,7 @@ class AbstractPillarTest(TestMinimal):
 | 
			
		||||
 | 
			
		||||
        return user_id
 | 
			
		||||
 | 
			
		||||
    def create_node(self, node_doc):
 | 
			
		||||
    def create_node(self, node_doc) -> ObjectId:
 | 
			
		||||
        """Creates a node, returning its ObjectId. """
 | 
			
		||||
 | 
			
		||||
        with self.app.test_request_context():
 | 
			
		||||
@@ -406,7 +435,7 @@ class AbstractPillarTest(TestMinimal):
 | 
			
		||||
        """Sets up Responses to mock unhappy validation flow."""
 | 
			
		||||
 | 
			
		||||
        responses.add(responses.POST,
 | 
			
		||||
                      '%s/u/validate_token' % self.app.config['BLENDER_ID_ENDPOINT'],
 | 
			
		||||
                      urljoin(self.app.config['BLENDER_ID_ENDPOINT'], 'u/validate_token'),
 | 
			
		||||
                      json={'status': 'fail'},
 | 
			
		||||
                      status=403)
 | 
			
		||||
 | 
			
		||||
@@ -414,7 +443,7 @@ class AbstractPillarTest(TestMinimal):
 | 
			
		||||
        """Sets up Responses to mock happy validation flow."""
 | 
			
		||||
 | 
			
		||||
        responses.add(responses.POST,
 | 
			
		||||
                      '%s/u/validate_token' % self.app.config['BLENDER_ID_ENDPOINT'],
 | 
			
		||||
                      urljoin(self.app.config['BLENDER_ID_ENDPOINT'], 'u/validate_token'),
 | 
			
		||||
                      json=BLENDER_ID_USER_RESPONSE,
 | 
			
		||||
                      status=200)
 | 
			
		||||
 | 
			
		||||
@@ -485,11 +514,10 @@ class AbstractPillarTest(TestMinimal):
 | 
			
		||||
 | 
			
		||||
    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,
 | 
			
		||||
                       environ_overrides=None):
 | 
			
		||||
                       environ_overrides=None) -> flask.wrappers.Response:
 | 
			
		||||
        """Performs a HTTP request to the server."""
 | 
			
		||||
 | 
			
		||||
        from pillar.api.utils import dumps
 | 
			
		||||
        import json as mod_json
 | 
			
		||||
 | 
			
		||||
        headers = headers or {}
 | 
			
		||||
        environ_overrides = environ_overrides or {}
 | 
			
		||||
@@ -522,29 +550,21 @@ class AbstractPillarTest(TestMinimal):
 | 
			
		||||
                             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
 | 
			
		||||
 | 
			
		||||
    def get(self, *args, **kwargs):
 | 
			
		||||
    def get(self, *args, **kwargs) -> flask.wrappers.Response:
 | 
			
		||||
        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)
 | 
			
		||||
 | 
			
		||||
    def put(self, *args, **kwargs):
 | 
			
		||||
    def put(self, *args, **kwargs) -> flask.wrappers.Response:
 | 
			
		||||
        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)
 | 
			
		||||
 | 
			
		||||
    def patch(self, *args, **kwargs):
 | 
			
		||||
    def patch(self, *args, **kwargs) -> flask.wrappers.Response:
 | 
			
		||||
        return self.client_request('PATCH', *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def assertAllowsAccess(self,
 | 
			
		||||
@@ -561,7 +581,7 @@ class AbstractPillarTest(TestMinimal):
 | 
			
		||||
            raise TypeError('expected_user_id should be a string or ObjectId, '
 | 
			
		||||
                            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:
 | 
			
		||||
            self.assertEqual(resp['_id'], str(expected_user_id))
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,9 @@
 | 
			
		||||
"""Flask configuration file for unit testing."""
 | 
			
		||||
 | 
			
		||||
BLENDER_ID_ENDPOINT = 'http://127.0.0.1:8001'  # nonexistant server, no trailing slash!
 | 
			
		||||
BLENDER_ID_ENDPOINT = 'http://id.local:8001/'  # Non existant server
 | 
			
		||||
 | 
			
		||||
SERVER_NAME = 'localhost'
 | 
			
		||||
PILLAR_SERVER_ENDPOINT = 'http://localhost/api/'
 | 
			
		||||
SERVER_NAME = 'localhost.local'
 | 
			
		||||
PILLAR_SERVER_ENDPOINT = 'http://localhost.local/api/'
 | 
			
		||||
 | 
			
		||||
MAIN_PROJECT_ID = '5672beecc0261b2005ed1a33'
 | 
			
		||||
 | 
			
		||||
@@ -26,7 +26,6 @@ OAUTH_CREDENTIALS = {
 | 
			
		||||
    'blender-id': {
 | 
			
		||||
        'id': 'blender-id-app-id',
 | 
			
		||||
        'secret': 'blender-id–secret',
 | 
			
		||||
        'base_url': 'http://blender-id:8000/'
 | 
			
		||||
    },
 | 
			
		||||
    'facebook': {
 | 
			
		||||
        'id': 'fb-app-id',
 | 
			
		||||
@@ -45,3 +44,5 @@ ELASTIC_INDICES = {
 | 
			
		||||
 | 
			
		||||
# MUST be 8 characters long, see pillar.flask_extra.HashedPathConverter
 | 
			
		||||
STATIC_FILE_HASH = 'abcd1234'
 | 
			
		||||
 | 
			
		||||
CACHE_NO_NULL_WARNING = True
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
from pillar.api.eve_settings import *
 | 
			
		||||
 | 
			
		||||
MONGO_DBNAME = 'pillar_test'
 | 
			
		||||
MONGO_USERNAME = None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def override_eve():
 | 
			
		||||
@@ -10,5 +11,7 @@ def override_eve():
 | 
			
		||||
    test_settings.MONGO_HOST = MONGO_HOST
 | 
			
		||||
    test_settings.MONGO_PORT = MONGO_PORT
 | 
			
		||||
    test_settings.MONGO_DBNAME = MONGO_DBNAME
 | 
			
		||||
    test_settings.MONGO1_USERNAME = MONGO_USERNAME
 | 
			
		||||
    tests.MONGO_HOST = MONGO_HOST
 | 
			
		||||
    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):
 | 
			
		||||
    # Ugly hard-coded schema.
 | 
			
		||||
    fake_schema = {
 | 
			
		||||
        'slug': schema_prop['propertyschema'],
 | 
			
		||||
        'slug': schema_prop['keyschema'],
 | 
			
		||||
        'oid': schema_prop['valueschema']['schema']['oid'],
 | 
			
		||||
    }
 | 
			
		||||
    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.url = url_for_node(node=post)
 | 
			
		||||
 | 
			
		||||
    # Use the *_main_project.html template for the main blog
 | 
			
		||||
    is_main_project = project_id == current_app.config['MAIN_PROJECT_ID']
 | 
			
		||||
    main_project_template = '_main_project' if is_main_project else ''
 | 
			
		||||
    main_project_template = '_main_project'
 | 
			
		||||
    index_arch = 'archive' if archive else 'index'
 | 
			
		||||
    template_path = f'nodes/custom/blog/{index_arch}{main_project_template}.html',
 | 
			
		||||
    template_path = f'nodes/custom/blog/{index_arch}.html',
 | 
			
		||||
 | 
			
		||||
    if url:
 | 
			
		||||
        template_path = f'nodes/custom/post/view{main_project_template}.html',
 | 
			
		||||
 | 
			
		||||
        post = Node.find_one({
 | 
			
		||||
            'where': {'parent': blog._id, 'properties.url': url},
 | 
			
		||||
            'embedded': {'node_type': 1, 'user': 1},
 | 
			
		||||
@@ -95,6 +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)
 | 
			
		||||
 | 
			
		||||
    # Use functools.partial so we can later pass page=X.
 | 
			
		||||
    is_main_project = project_id == current_app.config['MAIN_PROJECT_ID']
 | 
			
		||||
    if is_main_project:
 | 
			
		||||
        url_func = functools.partial(url_for, 'main.main_blog_archive')
 | 
			
		||||
    else:
 | 
			
		||||
@@ -121,7 +116,7 @@ def posts_view(project_id=None, project_url=None, url=None, *, archive=False, pa
 | 
			
		||||
    return render_template(
 | 
			
		||||
        template_path,
 | 
			
		||||
        blog=blog,
 | 
			
		||||
        node=post,
 | 
			
		||||
        node=post,  # node is used by the generic comments rendering (see custom/_scripts.pug)
 | 
			
		||||
        posts=posts._items,
 | 
			
		||||
        posts_meta=pmeta,
 | 
			
		||||
        more_posts_available=pmeta['total'] > pmeta['max_results'],
 | 
			
		||||
 
 | 
			
		||||
@@ -94,6 +94,16 @@ def find_for_post(project, node):
 | 
			
		||||
                   url=node.properties.url)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@register_node_finder('page')
 | 
			
		||||
def find_for_page(project, node):
 | 
			
		||||
    """Returns the URL for a page."""
 | 
			
		||||
 | 
			
		||||
    project_id = project['_id']
 | 
			
		||||
 | 
			
		||||
    the_project = project_url(project_id, project=project)
 | 
			
		||||
    return url_for('projects.view_node', project_url=the_project.url, node_id=node.properties.url)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def find_for_other(project, node):
 | 
			
		||||
    """Fallback: Assets, textures, and other node types.
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,11 @@
 | 
			
		||||
import functools
 | 
			
		||||
import logging
 | 
			
		||||
import typing
 | 
			
		||||
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
from datetime import date
 | 
			
		||||
import pillarsdk
 | 
			
		||||
from flask import current_app
 | 
			
		||||
from flask_wtf import Form
 | 
			
		||||
from flask_wtf import FlaskForm
 | 
			
		||||
from wtforms import StringField
 | 
			
		||||
from wtforms import DateField
 | 
			
		||||
from wtforms import SelectField
 | 
			
		||||
@@ -17,6 +18,8 @@ from wtforms import DateTimeField
 | 
			
		||||
from wtforms import SelectMultipleField
 | 
			
		||||
from wtforms import FieldList
 | 
			
		||||
from wtforms.validators import DataRequired
 | 
			
		||||
 | 
			
		||||
from pillar import current_app
 | 
			
		||||
from pillar.web.utils import system_util
 | 
			
		||||
from pillar.web.utils.forms import FileSelectField
 | 
			
		||||
from pillar.web.utils.forms import CustomFormField
 | 
			
		||||
@@ -44,6 +47,13 @@ def iter_node_properties(node_type):
 | 
			
		||||
        yield prop_name, prop_schema, prop_fschema
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@functools.lru_cache(maxsize=1)
 | 
			
		||||
def tag_choices() -> typing.List[typing.Tuple[str, str]]:
 | 
			
		||||
    """Return (value, label) tuples for the NODE_TAGS config setting."""
 | 
			
		||||
    tags = current_app.config.get('NODE_TAGS') or []
 | 
			
		||||
    return [(tag, tag.title()) for tag in tags]  # (value, label) tuples
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def add_form_properties(form_class, node_type):
 | 
			
		||||
    """Add fields to a form based on the node and form schema provided.
 | 
			
		||||
    :type node_schema: dict
 | 
			
		||||
@@ -60,7 +70,9 @@ def add_form_properties(form_class, node_type):
 | 
			
		||||
        # Recursive call if detects a dict
 | 
			
		||||
        field_type = schema_prop['type']
 | 
			
		||||
 | 
			
		||||
        if field_type == 'dict':
 | 
			
		||||
        if prop_name == 'tags' and field_type == 'list':
 | 
			
		||||
            field = SelectMultipleField(choices=tag_choices())
 | 
			
		||||
        elif field_type == 'dict':
 | 
			
		||||
            assert prop_name == 'attachments'
 | 
			
		||||
            field = attachments.attachment_form_group_create(schema_prop)
 | 
			
		||||
        elif field_type == 'list':
 | 
			
		||||
@@ -110,7 +122,7 @@ def get_node_form(node_type):
 | 
			
		||||
    :param node_type: Describes the node type via dyn_schema, form_schema and
 | 
			
		||||
    parent
 | 
			
		||||
    """
 | 
			
		||||
    class ProceduralForm(Form):
 | 
			
		||||
    class ProceduralForm(FlaskForm):
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    parent_prop = node_type['parent']
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
from flask_wtf import Form
 | 
			
		||||
from flask_wtf import FlaskForm
 | 
			
		||||
from wtforms import StringField
 | 
			
		||||
from wtforms import BooleanField
 | 
			
		||||
from wtforms import HiddenField
 | 
			
		||||
@@ -12,7 +12,7 @@ from pillar.web import system_util
 | 
			
		||||
from pillar.web.utils.forms import FileSelectField, JSONRequired
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ProjectForm(Form):
 | 
			
		||||
class ProjectForm(FlaskForm):
 | 
			
		||||
    project_id = HiddenField('project_id', validators=[DataRequired()])
 | 
			
		||||
    name = StringField('Name', validators=[DataRequired()])
 | 
			
		||||
    url = StringField('Url', validators=[DataRequired()])
 | 
			
		||||
@@ -32,7 +32,7 @@ class ProjectForm(Form):
 | 
			
		||||
    picture_square = FileSelectField('Picture square', file_format='image')
 | 
			
		||||
 | 
			
		||||
    def validate(self):
 | 
			
		||||
        rv = Form.validate(self)
 | 
			
		||||
        rv = FlaskForm.validate(self)
 | 
			
		||||
        if not rv:
 | 
			
		||||
            return False
 | 
			
		||||
 | 
			
		||||
@@ -54,7 +54,7 @@ class ProjectForm(Form):
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NodeTypeForm(Form):
 | 
			
		||||
class NodeTypeForm(FlaskForm):
 | 
			
		||||
    project_id = HiddenField('project_id', validators=[DataRequired()])
 | 
			
		||||
    name = StringField('Name', validators=[DataRequired()])
 | 
			
		||||
    parent = StringField('Parent')
 | 
			
		||||
 
 | 
			
		||||
@@ -24,6 +24,7 @@ from pillar import current_app
 | 
			
		||||
from pillar.api.utils import utcnow
 | 
			
		||||
from pillar.web import system_util
 | 
			
		||||
from pillar.web import utils
 | 
			
		||||
from pillar.web.nodes import finders
 | 
			
		||||
from pillar.web.utils.jstree import jstree_get_children
 | 
			
		||||
import pillar.extension
 | 
			
		||||
 | 
			
		||||
@@ -302,6 +303,52 @@ def view(project_url):
 | 
			
		||||
                                         'header_video_node': header_video_node})
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def project_navigation_links(project, api) -> list:
 | 
			
		||||
    """Returns a list of nodes for the project, for top navigation display.
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        project: A Project object.
 | 
			
		||||
        api: the api client credential.
 | 
			
		||||
 | 
			
		||||
    Returns:
 | 
			
		||||
        A list of links for the Project.
 | 
			
		||||
        For example we display a link to the project blog if present, as well
 | 
			
		||||
        as pages. The list is structured as follows:
 | 
			
		||||
 | 
			
		||||
        [{'url': '/p/spring/about', 'label': 'About'},
 | 
			
		||||
        {'url': '/p/spring/blog', 'label': 'Blog'}]
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    links = []
 | 
			
		||||
 | 
			
		||||
    # Fetch the blog
 | 
			
		||||
    blog = Node.find_first({
 | 
			
		||||
        'where': {'project': project._id, 'node_type': 'blog', '_deleted': {'$ne': True}},
 | 
			
		||||
        'projection': {
 | 
			
		||||
            'name': 1,
 | 
			
		||||
        }
 | 
			
		||||
    }, api=api)
 | 
			
		||||
 | 
			
		||||
    if blog:
 | 
			
		||||
        links.append({'url': finders.find_url_for_node(blog), 'label': blog.name})
 | 
			
		||||
 | 
			
		||||
    # 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):
 | 
			
		||||
    project.picture_square = utils.get_file(project.picture_square, 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)
 | 
			
		||||
 | 
			
		||||
    navigation_links = project_navigation_links(project, api)
 | 
			
		||||
 | 
			
		||||
    return render_template(template_name,
 | 
			
		||||
                           api=api,
 | 
			
		||||
                           project=project,
 | 
			
		||||
@@ -378,6 +427,7 @@ def render_project(project, api, extra_context=None, template_name=None):
 | 
			
		||||
                           show_project=True,
 | 
			
		||||
                           og_picture=project.picture_header,
 | 
			
		||||
                           activity_stream=activity_stream,
 | 
			
		||||
                           navigation_links=navigation_links,
 | 
			
		||||
                           extension_sidebar_links=extension_sidebar_links,
 | 
			
		||||
                           **extra_context)
 | 
			
		||||
 | 
			
		||||
@@ -447,16 +497,14 @@ def view_node(project_url, node_id):
 | 
			
		||||
 | 
			
		||||
    # Append _theatre to load the proper template
 | 
			
		||||
    theatre = '_theatre' if theatre_mode else ''
 | 
			
		||||
    navigation_links = project_navigation_links(project, api)
 | 
			
		||||
 | 
			
		||||
    if node.node_type == 'page':
 | 
			
		||||
        pages = Node.all({
 | 
			
		||||
            'where': {'project': project._id, 'node_type': 'page'},
 | 
			
		||||
            'projection': {'name': 1}}, api=api)
 | 
			
		||||
        return render_template('nodes/custom/page/view_embed.html',
 | 
			
		||||
                               api=api,
 | 
			
		||||
                               node=node,
 | 
			
		||||
                               project=project,
 | 
			
		||||
                               pages=pages._items,
 | 
			
		||||
                               navigation_links=navigation_links,
 | 
			
		||||
                               og_picture=og_picture,)
 | 
			
		||||
 | 
			
		||||
    extension_sidebar_links = current_app.extension_sidebar_links(project)
 | 
			
		||||
@@ -468,6 +516,7 @@ def view_node(project_url, node_id):
 | 
			
		||||
                           show_node=True,
 | 
			
		||||
                           show_project=False,
 | 
			
		||||
                           og_picture=og_picture,
 | 
			
		||||
                           navigation_links=navigation_links,
 | 
			
		||||
                           extension_sidebar_links=extension_sidebar_links)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -872,12 +872,6 @@
 | 
			
		||||
      "code": 61930,
 | 
			
		||||
      "src": "fontawesome"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "uid": "31972e4e9d080eaa796290349ae6c1fd",
 | 
			
		||||
      "css": "users",
 | 
			
		||||
      "code": 59502,
 | 
			
		||||
      "src": "fontawesome"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "uid": "c8585e1e5b0467f28b70bce765d5840c",
 | 
			
		||||
      "css": "clipboard-copy",
 | 
			
		||||
@@ -990,6 +984,30 @@
 | 
			
		||||
      "code": 59394,
 | 
			
		||||
      "src": "entypo"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "uid": "347c38a8b96a509270fdcabc951e7571",
 | 
			
		||||
      "css": "database",
 | 
			
		||||
      "code": 61888,
 | 
			
		||||
      "src": "fontawesome"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "uid": "3a6f0140c3a390bdb203f56d1bfdefcb",
 | 
			
		||||
      "css": "speed",
 | 
			
		||||
      "code": 59471,
 | 
			
		||||
      "src": "entypo"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "uid": "4c1ef492f1d2c39a2250ae457cee2a6e",
 | 
			
		||||
      "css": "social-instagram",
 | 
			
		||||
      "code": 61805,
 | 
			
		||||
      "src": "fontawesome"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "uid": "e36d581e4f2844db345bddc205d15dda",
 | 
			
		||||
      "css": "users",
 | 
			
		||||
      "code": 59507,
 | 
			
		||||
      "src": "elusive"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "uid": "053a214a098a9453877363eeb45f004e",
 | 
			
		||||
      "css": "log-in",
 | 
			
		||||
 
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							@@ -33,7 +33,8 @@ def get_user_info(user_id):
 | 
			
		||||
    # TODO: put those fields into a config var or module-level global.
 | 
			
		||||
    return {'email': user.email,
 | 
			
		||||
            'full_name': user.full_name,
 | 
			
		||||
            'username': user.username}
 | 
			
		||||
            'username': user.username,
 | 
			
		||||
            'badges_html': (user.badges and user.badges.html) or ''}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def setup_app(app):
 | 
			
		||||
 
 | 
			
		||||
@@ -12,14 +12,6 @@ from pillar.sdk import FlaskInternalApi
 | 
			
		||||
log = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def blender_id_endpoint():
 | 
			
		||||
    """Gets the endpoint for the authentication API. If the env variable
 | 
			
		||||
    is defined, it's possible to override the (default) production address.
 | 
			
		||||
    """
 | 
			
		||||
    return os.environ.get('BLENDER_ID_ENDPOINT',
 | 
			
		||||
                          "https://www.blender.org/id").rstrip('/')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def pillar_server_endpoint():
 | 
			
		||||
    """Gets the endpoint for the authentication API. If the env variable
 | 
			
		||||
    is defined, we will use the one from the config object.
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
from flask_login import current_user
 | 
			
		||||
from flask_wtf import Form
 | 
			
		||||
from flask_wtf import FlaskForm
 | 
			
		||||
from pillar.web import system_util
 | 
			
		||||
from pillarsdk.users import User
 | 
			
		||||
 | 
			
		||||
@@ -14,7 +14,7 @@ from wtforms.validators import Regexp
 | 
			
		||||
import wtforms.validators as wtvalid
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserLoginForm(Form):
 | 
			
		||||
class UserLoginForm(FlaskForm):
 | 
			
		||||
    username = StringField('Username', validators=[DataRequired()])
 | 
			
		||||
    password = PasswordField('Password', validators=[DataRequired()])
 | 
			
		||||
    remember_me = BooleanField('Remember Me')
 | 
			
		||||
@@ -23,7 +23,7 @@ class UserLoginForm(Form):
 | 
			
		||||
        super(UserLoginForm, self).__init__(csrf_enabled=False, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserProfileForm(Form):
 | 
			
		||||
class UserProfileForm(FlaskForm):
 | 
			
		||||
    username = StringField('Username', validators=[DataRequired(), Length(
 | 
			
		||||
        min=3, max=128, message="Min. 3, max. 128 chars please"), Regexp(
 | 
			
		||||
        r'^[\w.@+-]+$', message="Please do not use spaces")])
 | 
			
		||||
@@ -52,7 +52,7 @@ class UserProfileForm(Form):
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserSettingsEmailsForm(Form):
 | 
			
		||||
class UserSettingsEmailsForm(FlaskForm):
 | 
			
		||||
    choices = [
 | 
			
		||||
        (1, 'Keep me updated with Blender Cloud news.'),
 | 
			
		||||
        (0, 'Do not mail me news update.')]
 | 
			
		||||
@@ -74,7 +74,7 @@ class RolesField(SelectMultipleField):
 | 
			
		||||
        return current_app.user_roles
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserEditForm(Form):
 | 
			
		||||
class UserEditForm(FlaskForm):
 | 
			
		||||
    roles = RolesField('Roles')
 | 
			
		||||
    email = StringField(
 | 
			
		||||
        validators=[wtvalid.DataRequired(), wtvalid.Email()],
 | 
			
		||||
 
 | 
			
		||||
@@ -48,6 +48,10 @@ def oauth_authorize(provider):
 | 
			
		||||
 | 
			
		||||
@blueprint.route('/oauth/<provider>/authorized')
 | 
			
		||||
def oauth_callback(provider):
 | 
			
		||||
    import datetime
 | 
			
		||||
    from pillar.api.utils.authentication import store_token
 | 
			
		||||
    from pillar.api.utils import utcnow
 | 
			
		||||
 | 
			
		||||
    if current_user.is_authenticated:
 | 
			
		||||
        return redirect(url_for('main.homepage'))
 | 
			
		||||
 | 
			
		||||
@@ -65,7 +69,17 @@ def oauth_callback(provider):
 | 
			
		||||
    user_info = {'id': oauth_user.id, 'email': oauth_user.email, 'full_name': ''}
 | 
			
		||||
    db_user = find_user_in_db(user_info, provider=provider)
 | 
			
		||||
    db_id, status = upsert_user(db_user)
 | 
			
		||||
    token = generate_and_store_token(db_id)
 | 
			
		||||
 | 
			
		||||
    # TODO(Sybren): If the user doesn't have any badges, but the access token
 | 
			
		||||
    # does have 'badge' scope, we should fetch the badges in the background.
 | 
			
		||||
 | 
			
		||||
    if oauth_user.access_token:
 | 
			
		||||
        # TODO(Sybren): make nr of days configurable, or get from OAuthSignIn subclass.
 | 
			
		||||
        token_expiry = utcnow() + datetime.timedelta(days=15)
 | 
			
		||||
        token = store_token(db_id, oauth_user.access_token, token_expiry,
 | 
			
		||||
                            oauth_scopes=oauth_user.scopes)
 | 
			
		||||
    else:
 | 
			
		||||
        token = generate_and_store_token(db_id)
 | 
			
		||||
 | 
			
		||||
    # Login user
 | 
			
		||||
    pillar.auth.login_user(token['token'], load_from_db=True)
 | 
			
		||||
 
 | 
			
		||||
@@ -62,7 +62,7 @@ def jstree_get_children(node_id, project_id=None):
 | 
			
		||||
        'where': {
 | 
			
		||||
            '$and': [
 | 
			
		||||
                {'node_type': {'$regex': '^(?!attract_)'}},
 | 
			
		||||
                {'node_type': {'$not': {'$in': ['comment', 'post']}}},
 | 
			
		||||
                {'node_type': {'$not': {'$in': ['comment', 'post', 'blog', 'page']}}},
 | 
			
		||||
            ],
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -5,17 +5,17 @@ attrs==16.2.0
 | 
			
		||||
algoliasearch==1.12.0
 | 
			
		||||
bcrypt==3.1.3
 | 
			
		||||
blinker==1.4
 | 
			
		||||
bleach==1.4.3
 | 
			
		||||
celery[redis]==4.0.2
 | 
			
		||||
bleach==2.1.3
 | 
			
		||||
celery[redis]==4.2.1
 | 
			
		||||
CommonMark==0.7.2
 | 
			
		||||
elasticsearch==6.1.1
 | 
			
		||||
elasticsearch-dsl==6.1.0
 | 
			
		||||
Eve==0.7.3
 | 
			
		||||
Flask==0.12
 | 
			
		||||
Eve==0.8
 | 
			
		||||
Flask==1.0.2
 | 
			
		||||
Flask-Babel==0.11.2
 | 
			
		||||
Flask-Cache==0.13.1
 | 
			
		||||
Flask-Caching==1.4.0
 | 
			
		||||
Flask-Script==2.0.6
 | 
			
		||||
Flask-Login==0.3.2
 | 
			
		||||
Flask-Login==0.4.1
 | 
			
		||||
Flask-WTF==0.14.2
 | 
			
		||||
gcloud==0.12.0
 | 
			
		||||
google-apitools==0.4.11
 | 
			
		||||
@@ -27,37 +27,49 @@ Pillow==4.1.1
 | 
			
		||||
python-dateutil==2.5.3
 | 
			
		||||
rauth==0.7.3
 | 
			
		||||
raven[flask]==6.3.0
 | 
			
		||||
requests==2.13.0
 | 
			
		||||
redis==2.10.5
 | 
			
		||||
shortcodes==2.5.0
 | 
			
		||||
WebOb==1.5.0
 | 
			
		||||
wheel==0.29.0
 | 
			
		||||
zencoder==0.6.5
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Secondary requirements
 | 
			
		||||
amqp==2.1.4
 | 
			
		||||
billiard==3.5.0.2
 | 
			
		||||
Flask-PyMongo==0.4.1
 | 
			
		||||
-e git+https://github.com/armadillica/cerberus.git@sybren-0.9#egg=Cerberus
 | 
			
		||||
Events==0.2.2
 | 
			
		||||
future==0.15.2
 | 
			
		||||
html5lib==0.9999999
 | 
			
		||||
googleapis-common-protos==1.1.0
 | 
			
		||||
amqp==2.3.2
 | 
			
		||||
asn1crypto==0.24.0
 | 
			
		||||
Babel==2.6.0
 | 
			
		||||
billiard==3.5.0.4
 | 
			
		||||
Cerberus==1.2
 | 
			
		||||
cffi==1.10.0
 | 
			
		||||
click==6.7
 | 
			
		||||
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
 | 
			
		||||
Jinja2==2.9.6
 | 
			
		||||
kombu==4.0.2
 | 
			
		||||
oauth2client==2.0.2
 | 
			
		||||
oauthlib==2.0.1
 | 
			
		||||
olefile==0.44
 | 
			
		||||
protobuf==3.0.0b2.post2
 | 
			
		||||
protorpc==0.11.1
 | 
			
		||||
pyasn1-modules==0.0.8
 | 
			
		||||
pymongo==3.4.0
 | 
			
		||||
pytz==2017.2
 | 
			
		||||
requests-oauthlib==0.7.0
 | 
			
		||||
Jinja2==2.10
 | 
			
		||||
kombu==4.2.1
 | 
			
		||||
oauth2client==4.1.2
 | 
			
		||||
oauthlib==2.1.0
 | 
			
		||||
olefile==0.45.1
 | 
			
		||||
protobuf==3.6.0
 | 
			
		||||
protorpc==0.12.0
 | 
			
		||||
pyasn1==0.4.4
 | 
			
		||||
pyasn1-modules==0.2.2
 | 
			
		||||
pycparser==2.17
 | 
			
		||||
pymongo==3.7.0
 | 
			
		||||
pyOpenSSL==16.2.0
 | 
			
		||||
pytz==2018.5
 | 
			
		||||
requests-oauthlib==1.0.0
 | 
			
		||||
rsa==3.4.2
 | 
			
		||||
simplejson==3.10.0
 | 
			
		||||
simplejson==3.16.0
 | 
			
		||||
six==1.10.0
 | 
			
		||||
urllib3==1.22
 | 
			
		||||
vine==1.1.3
 | 
			
		||||
WTForms==2.1
 | 
			
		||||
Werkzeug==0.11.15
 | 
			
		||||
vine==1.1.4
 | 
			
		||||
webencodings==0.5.1
 | 
			
		||||
Werkzeug==0.14.1
 | 
			
		||||
WTForms==2.2.1
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								setup.py
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								setup.py
									
									
									
									
									
								
							@@ -35,7 +35,7 @@ setuptools.setup(
 | 
			
		||||
    install_requires=[
 | 
			
		||||
        'Flask>=0.12',
 | 
			
		||||
        'Eve>=0.7.3',
 | 
			
		||||
        'Flask-Cache>=0.13.1',
 | 
			
		||||
        'Flask-Caching>=1.4.0',
 | 
			
		||||
        'Flask-Script>=2.0.5',
 | 
			
		||||
        'Flask-Login>=0.3.2',
 | 
			
		||||
        '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";
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
})();
 | 
			
		||||
 | 
			
		||||
@@ -64,4 +64,13 @@
 | 
			
		||||
		return this;
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	// jQuery's show() sets display as 'inline', this utility sets it to whatever we want.
 | 
			
		||||
	// Useful for buttons or links that need 'inline-block' or flex for correct padding and alignment.
 | 
			
		||||
	$.fn.displayAs = function(display_type) {
 | 
			
		||||
		if (typeof(display_type) === 'undefined') {
 | 
			
		||||
			display_type = 'block';
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		this.css('display', display_type);
 | 
			
		||||
	}
 | 
			
		||||
}(jQuery));
 | 
			
		||||
 
 | 
			
		||||
@@ -32,7 +32,7 @@ var DocumentTitleAPI = {
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/* Status Bar */
 | 
			
		||||
/* Status Bar * DEPRECATED * USE TOASTR INSTEAD */
 | 
			
		||||
function statusBarClear(delay_class, delay_html){
 | 
			
		||||
	var statusBar = $("#status-bar");
 | 
			
		||||
 | 
			
		||||
@@ -54,6 +54,7 @@ function statusBarClear(delay_class, delay_html){
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Status Bar * DEPRECATED - USE TOASTR INSTEAD * */
 | 
			
		||||
function statusBarSet(classes, html, icon_name, time){
 | 
			
		||||
	/* Utility to notify the user by temporarily flashing text on the project header
 | 
			
		||||
		 Usage:
 | 
			
		||||
 
 | 
			
		||||
@@ -66,12 +66,9 @@ function containerResizeY(window_height){
 | 
			
		||||
 | 
			
		||||
	var project_container = document.getElementById('project-container');
 | 
			
		||||
	var container_offset = project_container.offsetTop;
 | 
			
		||||
	var nav_header_height = $('#project_nav-header').height();
 | 
			
		||||
	var container_height = window_height - container_offset.top;
 | 
			
		||||
	var container_height_wheader = window_height - container_offset.top - nav_header_height;
 | 
			
		||||
	var window_height_minus_nav = window_height - nav_header_height - 1; // 1 is border width
 | 
			
		||||
 | 
			
		||||
	$('#project_context-header').width($('#project_context-container').width());
 | 
			
		||||
	var container_height_wheader = window_height - container_offset;
 | 
			
		||||
	var window_height_minus_nav = window_height - container_offset;
 | 
			
		||||
 | 
			
		||||
	if ($(window).width() > 768) {
 | 
			
		||||
		$('#project-container').css(
 | 
			
		||||
@@ -79,13 +76,14 @@ function containerResizeY(window_height){
 | 
			
		||||
			 'height': window_height_minus_nav + 'px'}
 | 
			
		||||
		);
 | 
			
		||||
 | 
			
		||||
		$('#project_nav-container, #project_tree, .project_split').css(
 | 
			
		||||
			{'max-height': (window_height_minus_nav - 50) + 'px',
 | 
			
		||||
			 'height': (window_height_minus_nav - 50) + 'px'}
 | 
			
		||||
		$('#project_nav-container, #project_tree').css(
 | 
			
		||||
			{'max-height': (window_height_minus_nav) + 'px',
 | 
			
		||||
			 'height': (window_height_minus_nav) + 'px'}
 | 
			
		||||
		);
 | 
			
		||||
 | 
			
		||||
		if (container_height > parseInt($('#project-container').css("min-height"))) {
 | 
			
		||||
			if (typeof projectTree !== "undefined"){
 | 
			
		||||
 | 
			
		||||
				$(projectTree).css(
 | 
			
		||||
					{'max-height': container_height_wheader + 'px',
 | 
			
		||||
					 'height': container_height_wheader + 'px'}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										202
									
								
								src/scripts/video_plugins.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										202
									
								
								src/scripts/video_plugins.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,202 @@
 | 
			
		||||
/* Video.JS plugin for keeping track of user's viewing progress.
 | 
			
		||||
   Also registers the analytics plugin.
 | 
			
		||||
 | 
			
		||||
Progress is reported after a number of seconds or a percentage
 | 
			
		||||
of the duration of the video, whichever comes first.
 | 
			
		||||
 | 
			
		||||
Example usage:
 | 
			
		||||
 | 
			
		||||
videojs(videoPlayerElement, options).ready(function() {
 | 
			
		||||
    let report_url = '{{ url_for("users_api.set_video_progress", video_id=node._id) }}';
 | 
			
		||||
    this.progressPlugin({'report_url': report_url});
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
// Report after progressing this many seconds video-time.
 | 
			
		||||
let PROGRESS_REPORT_INTERVAL_SEC = 30;
 | 
			
		||||
 | 
			
		||||
// Report after progressing this percentage of the entire video (scale 0-100).
 | 
			
		||||
let PROGRESS_REPORT_INTERVAL_PERC = 10;
 | 
			
		||||
 | 
			
		||||
// Don't report within this many milliseconds of wall-clock time of the previous report.
 | 
			
		||||
let PROGRESS_RELAXING_TIME_MSEC = 500;
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
var Plugin = videojs.getPlugin('plugin');
 | 
			
		||||
var VideoProgressPlugin = videojs.extend(Plugin, {
 | 
			
		||||
    constructor: function(player, options) {
 | 
			
		||||
        Plugin.call(this, player, options);
 | 
			
		||||
 | 
			
		||||
        this.last_wallclock_time_ms = 0;
 | 
			
		||||
        this.last_inspected_progress_in_sec = 0;
 | 
			
		||||
        this.last_reported_progress_in_sec = 0;
 | 
			
		||||
        this.last_reported_progress_in_perc = 0;
 | 
			
		||||
        this.report_url = options.report_url;
 | 
			
		||||
        this.fetch_progress_url = options.fetch_progress_url;
 | 
			
		||||
        this.reported_error = false;
 | 
			
		||||
        this.reported_looping = false;
 | 
			
		||||
 | 
			
		||||
        if (typeof this.report_url === 'undefined' || !this.report_url) {
 | 
			
		||||
            /* If we can't report anything, don't bother registering event handlers. */
 | 
			
		||||
            videojs.log('VideoProgressPlugin: no report_url option given. Not storing video progress.');
 | 
			
		||||
        } else {
 | 
			
		||||
            /* Those events will have 'this' bound to the player,
 | 
			
		||||
            * which is why we explicitly re-bind to 'this''. */
 | 
			
		||||
            player.on('timeupdate', this.on_timeupdate.bind(this));
 | 
			
		||||
            player.on('pause', this.on_pause.bind(this));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (typeof this.fetch_progress_url === 'undefined' || !this.fetch_progress_url) {
 | 
			
		||||
            /* If we can't report anything, don't bother registering event handlers. */
 | 
			
		||||
            videojs.log('VideoProgressPlugin: no fetch_progress_url option given. Not restoring video progress.');
 | 
			
		||||
        } else {
 | 
			
		||||
            this.resume_playback();
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    resume_playback: function() {
 | 
			
		||||
        let on_done = function(progress, status, xhr) {
 | 
			
		||||
            /* 'progress' is an object like:
 | 
			
		||||
               {"progress_in_sec": 3,
 | 
			
		||||
                "progress_in_percent": 51,
 | 
			
		||||
                "last_watched": "Fri, 31 Aug 2018 13:53:06 GMT",
 | 
			
		||||
                "done": true}
 | 
			
		||||
            */
 | 
			
		||||
            switch (xhr.status) {
 | 
			
		||||
            case 204: return; // no info found.
 | 
			
		||||
            case 200:
 | 
			
		||||
                /* Don't do anything when the progress is at 100%.
 | 
			
		||||
                 * Moving the current time to the end makes no sense then. */
 | 
			
		||||
                if (progress.progress_in_percent >= 100) return;
 | 
			
		||||
 | 
			
		||||
                /* Set the 'last reported' props before manipulating the
 | 
			
		||||
                 * player, so that the manipulation doesn't trigger more
 | 
			
		||||
                 * API calls to remember what we just restored. */
 | 
			
		||||
                this.last_reported_progress_in_sec = progress.progress_in_sec;
 | 
			
		||||
                this.last_reported_progress_in_perc = progress.progress_in_perc;
 | 
			
		||||
 | 
			
		||||
                console.log("Continuing playback at ", progress.progress_in_percent, "% from", progress.last_watched);
 | 
			
		||||
                this.player.currentTime(progress.progress_in_sec);
 | 
			
		||||
                this.player.play();
 | 
			
		||||
                return;
 | 
			
		||||
            default:
 | 
			
		||||
                console.log("Unknown code", xhr.status, "getting video progress information.");
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        $.get(this.fetch_progress_url)
 | 
			
		||||
        .fail(function(error) {
 | 
			
		||||
            console.log("Unable to fetch video progress information:", xhrErrorResponseMessage(error));
 | 
			
		||||
        })
 | 
			
		||||
        .done(on_done.bind(this));
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    /* Pausing playback should report the progress.
 | 
			
		||||
     * This function is also called when playback stops at the end of the video,
 | 
			
		||||
     * so it's important to report in this case; otherwise progress will never
 | 
			
		||||
     * reach 100%. */
 | 
			
		||||
    on_pause: function(event) {
 | 
			
		||||
        this.inspect_progress(true);
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    on_timeupdate: function() {
 | 
			
		||||
        this.inspect_progress(false);
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    inspect_progress: function(force_report) {
 | 
			
		||||
        // Don't report seeking when paused, only report actual playback.
 | 
			
		||||
        if (!force_report && this.player.paused()) return;
 | 
			
		||||
 | 
			
		||||
        let now_in_ms = new Date().getTime();
 | 
			
		||||
        if (!force_report && now_in_ms - this.last_wallclock_time_ms < PROGRESS_RELAXING_TIME_MSEC) {
 | 
			
		||||
            // We're trying too fast, don't bother doing any other calculation.
 | 
			
		||||
            // console.log('skipping, already reported', now_in_ms - this.last_wallclock_time_ms, 'ms ago.');
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let progress_in_sec = this.player.currentTime();
 | 
			
		||||
        let duration_in_sec = this.player.duration();
 | 
			
		||||
 | 
			
		||||
        /* Instead of reporting the current time, report reaching the end
 | 
			
		||||
         * of the video. This ensures that it's properly marked as 'done'. */
 | 
			
		||||
         if (!this.reported_looping) {
 | 
			
		||||
            let margin = 1.25 * PROGRESS_RELAXING_TIME_MSEC / 1000.0;
 | 
			
		||||
            let is_looping = progress_in_sec == 0 && duration_in_sec - this.last_inspected_progress_in_sec < margin;
 | 
			
		||||
            this.last_inspected_progress_in_sec = progress_in_sec;
 | 
			
		||||
            if (is_looping) {
 | 
			
		||||
                 this.reported_looping = true;
 | 
			
		||||
                 this.report(this.player.duration(), 100, now_in_ms);
 | 
			
		||||
                 return;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (Math.abs(progress_in_sec - this.last_reported_progress_in_sec) < 0.01) {
 | 
			
		||||
            // Already reported this, don't bother doing it again.
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        let progress_in_perc = 100 * progress_in_sec / duration_in_sec;
 | 
			
		||||
        let diff_sec = progress_in_sec - this.last_reported_progress_in_sec;
 | 
			
		||||
        let diff_perc = progress_in_perc - this.last_reported_progress_in_perc;
 | 
			
		||||
 | 
			
		||||
        if (!force_report
 | 
			
		||||
             && Math.abs(diff_perc) < PROGRESS_REPORT_INTERVAL_PERC
 | 
			
		||||
             && Math.abs(diff_sec) < PROGRESS_REPORT_INTERVAL_SEC) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.report(progress_in_sec, progress_in_perc, now_in_ms);
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    report: function(progress_in_sec, progress_in_perc, now_in_ms) {
 | 
			
		||||
        /* Store when we tried, not when we succeeded. This function can be
 | 
			
		||||
         * called every 15-250 milliseconds, so we don't want to retry with
 | 
			
		||||
         * that frequency. */
 | 
			
		||||
        this.last_wallclock_time_ms = now_in_ms;
 | 
			
		||||
 | 
			
		||||
        let on_fail = function(error) {
 | 
			
		||||
            /* Don't show (as in: a toastr popup) the error to the user,
 | 
			
		||||
             * as it doesn't impact their ability to play the video.
 | 
			
		||||
             * Also show the error only once, instead of spamming. */
 | 
			
		||||
             if (this.reported_error) return;
 | 
			
		||||
 | 
			
		||||
             let msg = xhrErrorResponseMessage(error);
 | 
			
		||||
             console.log('Unable to report viewing progress:', msg);
 | 
			
		||||
             this.reported_error = true;
 | 
			
		||||
        };
 | 
			
		||||
        let on_done = function() {
 | 
			
		||||
            this.last_reported_progress_in_sec = progress_in_sec;
 | 
			
		||||
            this.last_reported_progress_in_perc = progress_in_perc;
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        $.post(this.report_url, {
 | 
			
		||||
            progress_in_sec: progress_in_sec,
 | 
			
		||||
            progress_in_perc: Math.round(progress_in_perc),
 | 
			
		||||
        })
 | 
			
		||||
        .fail(on_fail.bind(this))
 | 
			
		||||
        .done(on_done.bind(this));
 | 
			
		||||
    },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
var RememberVolumePlugin = videojs.extend(Plugin, {
 | 
			
		||||
    constructor: function(player, options) {
 | 
			
		||||
        Plugin.call(this, player, options);
 | 
			
		||||
        player.on('volumechange', this.on_volumechange.bind(this));
 | 
			
		||||
        this.restore_volume();
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    restore_volume: function() {
 | 
			
		||||
        let volume_str = localStorage.getItem('video-player-volume');
 | 
			
		||||
        if (volume_str == null) return;
 | 
			
		||||
        this.player.volume(1.0 * volume_str);
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    on_volumechange: function(event) {
 | 
			
		||||
        localStorage.setItem('video-player-volume', this.player.volume());
 | 
			
		||||
    },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
// Register our watch-progress-bookkeeping plugin.
 | 
			
		||||
videojs.registerPlugin('progressPlugin', VideoProgressPlugin);
 | 
			
		||||
videojs.registerPlugin('rememberVolumePlugin', RememberVolumePlugin);
 | 
			
		||||
@@ -143,12 +143,17 @@ nav.sidebar
 | 
			
		||||
	left: 0
 | 
			
		||||
	width: $sidebar-width
 | 
			
		||||
	height: 100%
 | 
			
		||||
	background-color: $color-background-nav
 | 
			
		||||
	display: flex
 | 
			
		||||
	flex-direction: column
 | 
			
		||||
 | 
			
		||||
	> ul > li > .navbar-item
 | 
			
		||||
		padding-top: 10px
 | 
			
		||||
		padding-bottom: 10px
 | 
			
		||||
		background: red
 | 
			
		||||
 | 
			
		||||
	.dropdown
 | 
			
		||||
		min-width: $sidebar-width
 | 
			
		||||
 | 
			
		||||
		.dropdown-menu
 | 
			
		||||
			top: initial
 | 
			
		||||
			bottom: 3px
 | 
			
		||||
@@ -159,7 +164,7 @@ nav.sidebar
 | 
			
		||||
			li a
 | 
			
		||||
				justify-content: flex-start
 | 
			
		||||
 | 
			
		||||
	ul
 | 
			
		||||
	> ul
 | 
			
		||||
		width: 100%
 | 
			
		||||
		margin: 0
 | 
			
		||||
		padding: 0
 | 
			
		||||
@@ -172,25 +177,11 @@ nav.sidebar
 | 
			
		||||
 | 
			
		||||
			a.navbar-item, button
 | 
			
		||||
				display: flex
 | 
			
		||||
				color: $color-text-light-hint
 | 
			
		||||
				font-size: 1.5em
 | 
			
		||||
				align-items: center
 | 
			
		||||
				justify-content: center
 | 
			
		||||
				padding: 10px 0
 | 
			
		||||
				background: transparent
 | 
			
		||||
				border: none
 | 
			
		||||
				width: 100%
 | 
			
		||||
				text-decoration: none
 | 
			
		||||
 | 
			
		||||
				&:hover
 | 
			
		||||
					color: $color-text-light-primary
 | 
			
		||||
				&:active
 | 
			
		||||
					outline: none
 | 
			
		||||
 | 
			
		||||
				&.cloud
 | 
			
		||||
					i
 | 
			
		||||
						position: relative
 | 
			
		||||
						left: -4px
 | 
			
		||||
 | 
			
		||||
			a.dropdown-toggle
 | 
			
		||||
				padding: 0
 | 
			
		||||
@@ -408,3 +399,68 @@ nav.sidebar
 | 
			
		||||
			top: -1px
 | 
			
		||||
			left: -19px
 | 
			
		||||
			z-index: 1
 | 
			
		||||
 | 
			
		||||
$loader-bar-width: 100px
 | 
			
		||||
$loader-bar-height: 2px
 | 
			
		||||
.loader-bar
 | 
			
		||||
	bottom: 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-container
 | 
			
		||||
	max-width: $comments-width-max
 | 
			
		||||
	position: relative
 | 
			
		||||
	width: 100%
 | 
			
		||||
 | 
			
		||||
	#comments-reload
 | 
			
		||||
		text-align: center
 | 
			
		||||
@@ -314,9 +316,6 @@ $comments-width-max: 710px
 | 
			
		||||
					color: $color-success
 | 
			
		||||
 | 
			
		||||
.comment-reply
 | 
			
		||||
	&-container
 | 
			
		||||
		background-color: $color-background
 | 
			
		||||
 | 
			
		||||
	/* Little gravatar icon on the left */
 | 
			
		||||
	&-avatar
 | 
			
		||||
		img
 | 
			
		||||
@@ -333,7 +332,7 @@ $comments-width-max: 710px
 | 
			
		||||
		width: 100%
 | 
			
		||||
 | 
			
		||||
	&-field
 | 
			
		||||
		background-color: $color-background-dark
 | 
			
		||||
		background-color: $color-background-light
 | 
			
		||||
		border-radius: 3px
 | 
			
		||||
		box-shadow: inset 0 0 2px 0 rgba(darken($color-background-dark, 20%), .5)
 | 
			
		||||
		display: flex
 | 
			
		||||
@@ -342,6 +341,7 @@ $comments-width-max: 710px
 | 
			
		||||
 | 
			
		||||
		textarea
 | 
			
		||||
			+node-details-description
 | 
			
		||||
			background-color: $color-background-light
 | 
			
		||||
			border-bottom-right-radius: 0
 | 
			
		||||
			border-top-right-radius: 0
 | 
			
		||||
			border: none
 | 
			
		||||
@@ -376,7 +376,6 @@ $comments-width-max: 710px
 | 
			
		||||
 | 
			
		||||
		&.filled
 | 
			
		||||
			textarea
 | 
			
		||||
				background-color: $color-background-light
 | 
			
		||||
				border-bottom: thin solid $color-background
 | 
			
		||||
 | 
			
		||||
				&:focus
 | 
			
		||||
@@ -453,12 +452,17 @@ $comments-width-max: 710px
 | 
			
		||||
		transition: background-color 150ms ease-in-out, color 150ms ease-in-out
 | 
			
		||||
		width: 100px
 | 
			
		||||
 | 
			
		||||
		// The actual button for submitting the comment.
 | 
			
		||||
		button.comment-action-submit
 | 
			
		||||
			align-items: center
 | 
			
		||||
			background: transparent
 | 
			
		||||
			border: none
 | 
			
		||||
			border-top-left-radius: 0
 | 
			
		||||
			border-bottom-left-radius: 0
 | 
			
		||||
			color: $color-success
 | 
			
		||||
			cursor: pointer
 | 
			
		||||
			display: flex
 | 
			
		||||
			justify-content: center
 | 
			
		||||
			flex-direction: column
 | 
			
		||||
			height: 100%
 | 
			
		||||
			position: relative
 | 
			
		||||
@@ -466,8 +470,12 @@ $comments-width-max: 710px
 | 
			
		||||
			white-space: nowrap
 | 
			
		||||
			width: 100%
 | 
			
		||||
 | 
			
		||||
			&:hover
 | 
			
		||||
				background: rgba($color-success, .1)
 | 
			
		||||
 | 
			
		||||
			&:focus
 | 
			
		||||
				background: lighten($color-success, 10%)
 | 
			
		||||
				color: $white
 | 
			
		||||
 | 
			
		||||
			&.submitting
 | 
			
		||||
				color: $color-info
 | 
			
		||||
 
 | 
			
		||||
@@ -12,9 +12,10 @@ $color-background-active-dark: hsl(hue($color-background-active), 50%, 50%) !def
 | 
			
		||||
$font-body: 'Roboto' !default
 | 
			
		||||
$font-headings: 'Lato' !default
 | 
			
		||||
$font-size: 14px !default
 | 
			
		||||
$font-size-xs: .75rem
 | 
			
		||||
$font-size-xxs: .65rem
 | 
			
		||||
 | 
			
		||||
$color-text: #4d4e53 !default
 | 
			
		||||
 | 
			
		||||
$color-text-dark: $color-text !default
 | 
			
		||||
$color-text-dark-primary: #646469 !default
 | 
			
		||||
$color-text-dark-secondary: #9E9FA2 !default
 | 
			
		||||
@@ -25,10 +26,11 @@ $color-text-light-primary: rgba($color-text-light, .87) !default
 | 
			
		||||
$color-text-light-secondary: rgba($color-text-light, .54) !default
 | 
			
		||||
$color-text-light-hint: rgba($color-text-light, .38) !default
 | 
			
		||||
 | 
			
		||||
$color-primary: #68B3C8 !default
 | 
			
		||||
$color-primary: #009eff !default
 | 
			
		||||
$color-primary-light: hsl(hue($color-primary), 30%, 90%) !default
 | 
			
		||||
$color-primary-dark: hsl(hue($color-primary), 80%, 30%) !default
 | 
			
		||||
$color-primary-accent:  hsl(hue($color-primary), 100%, 50%) !default
 | 
			
		||||
$primary-accent: #0bd
 | 
			
		||||
 | 
			
		||||
$color-secondary: #f42942 !default
 | 
			
		||||
$color-secondary-light: hsl(hue($color-secondary), 30%, 90%) !default
 | 
			
		||||
@@ -96,16 +98,16 @@ $screen-xs-max: $screen-sm-min - 1 !default
 | 
			
		||||
$screen-sm-max: $screen-md-min - 1 !default
 | 
			
		||||
$screen-md-max: $screen-lg-min - 1 !default
 | 
			
		||||
 | 
			
		||||
$sidebar-width: 50px !default
 | 
			
		||||
$sidebar-width: 40px !default
 | 
			
		||||
 | 
			
		||||
/* Project specifics */
 | 
			
		||||
$project_nav-width: 250px !default
 | 
			
		||||
$project-sidebar-width: 50px !default
 | 
			
		||||
$project_header-height: 50px !default
 | 
			
		||||
$project_footer-height: 30px !default
 | 
			
		||||
 | 
			
		||||
$navbar-height: 50px !default
 | 
			
		||||
$navbar-backdrop-height: 600px !default
 | 
			
		||||
$project_nav-width: 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_header-height: 40px !default
 | 
			
		||||
 | 
			
		||||
$node-type-asset_image: #e87d86 !default
 | 
			
		||||
$node-type-asset_file: #CC91C7 !default
 | 
			
		||||
@@ -125,3 +127,37 @@ $z-index-base: 13 !default
 | 
			
		||||
 | 
			
		||||
	@media (min-width: $screen-lg-min)
 | 
			
		||||
		width: 1270px
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
// Bootstrap overrides.
 | 
			
		||||
$enable-caret: false
 | 
			
		||||
 | 
			
		||||
$border-radius: .2rem
 | 
			
		||||
$btn-border-radius: $border-radius
 | 
			
		||||
 | 
			
		||||
$primary: $color-primary
 | 
			
		||||
 | 
			
		||||
$body-bg: $white
 | 
			
		||||
$body-color: $color-text
 | 
			
		||||
 | 
			
		||||
$color-background-nav: #fff
 | 
			
		||||
$link-color: $primary
 | 
			
		||||
 | 
			
		||||
$font-family-sans-serif: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, Helvetica, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"
 | 
			
		||||
$font-size-base: .9rem
 | 
			
		||||
 | 
			
		||||
$dropdown-border-width: 0
 | 
			
		||||
$dropdown-box-shadow: 0 10px 25px rgba($black, .1)
 | 
			
		||||
$dropdown-padding-y: 0
 | 
			
		||||
$dropdown-item-padding-y: .4rem
 | 
			
		||||
 | 
			
		||||
// Tooltips.
 | 
			
		||||
$tooltip-font-size: 0.83rem
 | 
			
		||||
$tooltip-max-width: auto
 | 
			
		||||
$tooltip-opacity: 1
 | 
			
		||||
 | 
			
		||||
$nav-link-height: 37px
 | 
			
		||||
$navbar-padding-x: 0
 | 
			
		||||
$navbar-padding-y: 0
 | 
			
		||||
 | 
			
		||||
$grid-breakpoints: (xs: 0,sm: 576px,md: 768px,lg: 1100px,xl: 1500px, xxl: 1800px)
 | 
			
		||||
 
 | 
			
		||||
@@ -60,14 +60,13 @@
 | 
			
		||||
 | 
			
		||||
#node-overlay
 | 
			
		||||
	#error-container
 | 
			
		||||
		position: fixed
 | 
			
		||||
		top: $navbar-height
 | 
			
		||||
		align-items: flex-start
 | 
			
		||||
		position: fixed
 | 
			
		||||
		top: $nav-link-height
 | 
			
		||||
 | 
			
		||||
		#error-box
 | 
			
		||||
			box-shadow: 0 0 25px rgba(black, .1), 0 0 50px rgba(black, .1)
 | 
			
		||||
			width: auto
 | 
			
		||||
			border-top-left-radius: 0
 | 
			
		||||
			border-top-right-radius: 0
 | 
			
		||||
			box-shadow: 0 0 25px rgba(black, .1), 0 0 50px rgba(black, .1)
 | 
			
		||||
			position: relative
 | 
			
		||||
			width: 100%
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,6 @@
 | 
			
		||||
		color: $color-primary
 | 
			
		||||
		cursor: pointer
 | 
			
		||||
		float: right
 | 
			
		||||
		font-family: $font-body
 | 
			
		||||
		height: initial
 | 
			
		||||
		margin: 0
 | 
			
		||||
		padding: 8px 10px 0 10px
 | 
			
		||||
@@ -25,13 +24,16 @@
 | 
			
		||||
			color: $color-secondary
 | 
			
		||||
 | 
			
		||||
#notifications-toggle
 | 
			
		||||
	color: $color-text
 | 
			
		||||
	cursor: pointer
 | 
			
		||||
	font-size: 1.5em
 | 
			
		||||
	position: relative
 | 
			
		||||
	user-select: none
 | 
			
		||||
 | 
			
		||||
	> i:before
 | 
			
		||||
		content: '\e815'
 | 
			
		||||
		font-size: 1.3em
 | 
			
		||||
		position: relative
 | 
			
		||||
		top: 2px
 | 
			
		||||
 | 
			
		||||
	&.has-notifications
 | 
			
		||||
		> i:before
 | 
			
		||||
@@ -46,10 +48,10 @@
 | 
			
		||||
		border-color: transparent transparent $color-background transparent
 | 
			
		||||
		border-style: solid
 | 
			
		||||
		border-width: 0 8px 8px 8px
 | 
			
		||||
		bottom: -15px
 | 
			
		||||
		bottom: -10px
 | 
			
		||||
		height: 0
 | 
			
		||||
		position: absolute
 | 
			
		||||
		right: 22px
 | 
			
		||||
		right: 7px
 | 
			
		||||
		visibility: hidden
 | 
			
		||||
		width: 0
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,20 +1,4 @@
 | 
			
		||||
body.organizations
 | 
			
		||||
	ul#sub-nav-tabs__list
 | 
			
		||||
		align-items: center
 | 
			
		||||
		display: flex
 | 
			
		||||
 | 
			
		||||
		li.result
 | 
			
		||||
			padding: 10px 20px
 | 
			
		||||
		li.create
 | 
			
		||||
			margin-left: auto
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
	.dashboard-secondary
 | 
			
		||||
		.box
 | 
			
		||||
			+container-box
 | 
			
		||||
			padding: 10px 20px
 | 
			
		||||
			margin: 0
 | 
			
		||||
 | 
			
		||||
	#item-details
 | 
			
		||||
		.organization
 | 
			
		||||
			label
 | 
			
		||||
 
 | 
			
		||||
@@ -409,7 +409,6 @@ a.page-card-cta
 | 
			
		||||
		display: block
 | 
			
		||||
		+position-center-translate
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		+media-xs
 | 
			
		||||
			display: none
 | 
			
		||||
		+media-sm
 | 
			
		||||
@@ -419,9 +418,6 @@ a.page-card-cta
 | 
			
		||||
		+media-lg
 | 
			
		||||
			width: 100%
 | 
			
		||||
 | 
			
		||||
.services.navbar-backdrop-overlay
 | 
			
		||||
	background: rgba(black, .5)
 | 
			
		||||
 | 
			
		||||
.services
 | 
			
		||||
	.page-card-side
 | 
			
		||||
		max-width: 500px
 | 
			
		||||
 
 | 
			
		||||
@@ -1,22 +1,16 @@
 | 
			
		||||
 | 
			
		||||
.dashboard-container
 | 
			
		||||
	section#home,
 | 
			
		||||
	section#projects
 | 
			
		||||
		background-color: $color-background
 | 
			
		||||
		border-bottom-left-radius: 3px
 | 
			
		||||
		border-bottom-right-radius: 3px
 | 
			
		||||
 | 
			
		||||
		nav#sub-nav-tabs.home,
 | 
			
		||||
		nav#sub-nav-tabs.projects
 | 
			
		||||
			background-color: white
 | 
			
		||||
			border-bottom: thin solid $color-background-dark
 | 
			
		||||
 | 
			
		||||
			li.nav-tabs__list-tab
 | 
			
		||||
				padding: 15px 20px 10px 20px
 | 
			
		||||
 | 
			
		||||
	section#home
 | 
			
		||||
		background-color: $color-background-dark
 | 
			
		||||
 | 
			
		||||
	nav.nav-tabs__tab
 | 
			
		||||
		display: none
 | 
			
		||||
		background-color: $color-background-light
 | 
			
		||||
@@ -287,9 +281,8 @@
 | 
			
		||||
				flex-direction: column
 | 
			
		||||
 | 
			
		||||
				.title
 | 
			
		||||
					font-size: 1.2em
 | 
			
		||||
					padding-bottom: 2px
 | 
			
		||||
					color: $color-text-dark-primary
 | 
			
		||||
					padding-bottom: 2px
 | 
			
		||||
 | 
			
		||||
				ul.meta
 | 
			
		||||
					font-size: .9em
 | 
			
		||||
 
 | 
			
		||||
@@ -92,19 +92,6 @@ ul.sharing-users-list
 | 
			
		||||
					&:hover
 | 
			
		||||
						color: lighten($color-danger, 10%)
 | 
			
		||||
 | 
			
		||||
.sharing-users-intro,
 | 
			
		||||
.sharing-users-info
 | 
			
		||||
	h4
 | 
			
		||||
		font-family: $font-body
 | 
			
		||||
 | 
			
		||||
.sharing-users-info
 | 
			
		||||
	padding-left: 15px
 | 
			
		||||
	border-left: thin solid $color-text-dark-hint
 | 
			
		||||
 | 
			
		||||
	p
 | 
			
		||||
		font:
 | 
			
		||||
			size: 1.1em
 | 
			
		||||
			weight: 300
 | 
			
		||||
 | 
			
		||||
.sharing-users-search
 | 
			
		||||
	.disabled
 | 
			
		||||
@@ -162,24 +149,26 @@ ul.list-generic
 | 
			
		||||
	list-style: none
 | 
			
		||||
 | 
			
		||||
	> li
 | 
			
		||||
		padding: 5px 0
 | 
			
		||||
		display: flex
 | 
			
		||||
		align-items: center
 | 
			
		||||
		border-top: thin solid $color-background
 | 
			
		||||
		display: flex
 | 
			
		||||
		padding: 5px 0
 | 
			
		||||
 | 
			
		||||
		&:first-child
 | 
			
		||||
			border-top: none
 | 
			
		||||
 | 
			
		||||
		&:hover .item a
 | 
			
		||||
			color: $color-primary
 | 
			
		||||
			color: $primary
 | 
			
		||||
 | 
			
		||||
		a
 | 
			
		||||
			flex: 1
 | 
			
		||||
 | 
			
		||||
			&.active
 | 
			
		||||
				color: $primary !important
 | 
			
		||||
				font-weight: bold
 | 
			
		||||
 | 
			
		||||
		.actions
 | 
			
		||||
			margin-left: auto
 | 
			
		||||
			.btn
 | 
			
		||||
				font-size: .7em
 | 
			
		||||
 | 
			
		||||
			span
 | 
			
		||||
				color: $color-text-dark-secondary
 | 
			
		||||
 
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -16,7 +16,7 @@ $search-hit-width_grid: 100px
 | 
			
		||||
			.search-hit-name
 | 
			
		||||
				font-weight: 400
 | 
			
		||||
				padding-top: 8px
 | 
			
		||||
				color: $color-primary-dark
 | 
			
		||||
				color: $primary
 | 
			
		||||
 | 
			
		||||
	.search-hit
 | 
			
		||||
		padding: 0
 | 
			
		||||
@@ -29,14 +29,13 @@ $search-hit-width_grid: 100px
 | 
			
		||||
		font:
 | 
			
		||||
			size: .9em
 | 
			
		||||
			weight: 400
 | 
			
		||||
			family: $font-body
 | 
			
		||||
			style: initial
 | 
			
		||||
		width: 100%
 | 
			
		||||
		+text-overflow-ellipsis
 | 
			
		||||
		+clearfix
 | 
			
		||||
 | 
			
		||||
		& em
 | 
			
		||||
			color: $color-primary-dark
 | 
			
		||||
			color: $primary
 | 
			
		||||
			font-style: normal
 | 
			
		||||
 | 
			
		||||
		&:hover
 | 
			
		||||
@@ -71,7 +70,7 @@ $search-hit-width_grid: 100px
 | 
			
		||||
		min-width: 350px
 | 
			
		||||
		border-bottom-left-radius: 3px
 | 
			
		||||
		border-bottom-right-radius: 3px
 | 
			
		||||
		border-top: 3px solid lighten($color-primary, 5%)
 | 
			
		||||
		border-top: 3px solid lighten($primary, 5%)
 | 
			
		||||
		overflow: hidden
 | 
			
		||||
 | 
			
		||||
		.tt-suggestion
 | 
			
		||||
@@ -93,235 +92,51 @@ $search-hit-width_grid: 100px
 | 
			
		||||
			&.tt-cursor:hover .search-hit
 | 
			
		||||
				background-color: lighten($color-background, 5%)
 | 
			
		||||
 | 
			
		||||
#search-container
 | 
			
		||||
	display: flex
 | 
			
		||||
	min-height: 600px
 | 
			
		||||
	background-color: white
 | 
			
		||||
.search-list
 | 
			
		||||
	width: 30%
 | 
			
		||||
 | 
			
		||||
	+media-lg
 | 
			
		||||
		padding-left: 0
 | 
			
		||||
		padding-right: 0
 | 
			
		||||
	.card-deck.card-deck-vertical
 | 
			
		||||
		.card .embed-responsive
 | 
			
		||||
			max-width: 80px
 | 
			
		||||
 | 
			
		||||
	#search-sidebar
 | 
			
		||||
		width: 20%
 | 
			
		||||
		background-color: $color-background-light
 | 
			
		||||
	input.search-field
 | 
			
		||||
		border: none
 | 
			
		||||
		border-bottom: 2px solid rgba($primary, .2)
 | 
			
		||||
		border-radius: 0
 | 
			
		||||
		width: 100%
 | 
			
		||||
		transition: border 100ms ease-in-out
 | 
			
		||||
 | 
			
		||||
		+media-lg
 | 
			
		||||
			border-top-left-radius: 3px
 | 
			
		||||
		&::placeholder
 | 
			
		||||
			color: $color-text-dark-secondary
 | 
			
		||||
		&:placeholder-shown
 | 
			
		||||
			border-bottom-color: $primary
 | 
			
		||||
 | 
			
		||||
		input.search-field
 | 
			
		||||
			background-color: $color-background-nav-dark
 | 
			
		||||
			font-size: 1.1em
 | 
			
		||||
			color: white
 | 
			
		||||
			margin-bottom: 10px
 | 
			
		||||
		&:focus
 | 
			
		||||
			outline: none
 | 
			
		||||
			border: none
 | 
			
		||||
			border-bottom: 2px solid rgba($color-primary, .2)
 | 
			
		||||
			border-radius: 0
 | 
			
		||||
			width: 100%
 | 
			
		||||
			padding: 5px 15px
 | 
			
		||||
			height: 50px
 | 
			
		||||
			transition: border 100ms ease-in-out
 | 
			
		||||
			border-bottom: 2px solid lighten($primary, 5%)
 | 
			
		||||
 | 
			
		||||
			&::placeholder
 | 
			
		||||
				color: $color-text-dark-secondary
 | 
			
		||||
			&:placeholder-shown
 | 
			
		||||
				border-bottom-color: $color-primary
 | 
			
		||||
.search-details
 | 
			
		||||
	width: 70%
 | 
			
		||||
 | 
			
		||||
			&:focus
 | 
			
		||||
				outline: none
 | 
			
		||||
				border: none
 | 
			
		||||
				border-bottom: 2px solid lighten($color-primary, 5%)
 | 
			
		||||
 | 
			
		||||
		.search-list-filters
 | 
			
		||||
			padding:
 | 
			
		||||
				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
 | 
			
		||||
#search-details
 | 
			
		||||
	position: relative
 | 
			
		||||
	#search-hit-container
 | 
			
		||||
		position: absolute // for scrollbars
 | 
			
		||||
		overflow-y: auto
 | 
			
		||||
 | 
			
		||||
		#hits
 | 
			
		||||
		#error_container
 | 
			
		||||
			position: relative
 | 
			
		||||
			width: 100%
 | 
			
		||||
			background: white
 | 
			
		||||
			padding: 20px
 | 
			
		||||
 | 
			
		||||
		#no-hits
 | 
			
		||||
			padding: 10px 15px
 | 
			
		||||
			color: $color-text-dark-secondary
 | 
			
		||||
 | 
			
		||||
		.search-hit
 | 
			
		||||
			#search-loading
 | 
			
		||||
				visibility: hidden
 | 
			
		||||
				background-color: transparent
 | 
			
		||||
				font:
 | 
			
		||||
					size: 1.5em
 | 
			
		||||
					weight: 600
 | 
			
		||||
				position: absolute
 | 
			
		||||
				top: 0
 | 
			
		||||
				left: 0
 | 
			
		||||
				right: 0
 | 
			
		||||
				bottom: 0
 | 
			
		||||
				z-index: $z-index-base + 5
 | 
			
		||||
				opacity: 0
 | 
			
		||||
				cursor: default
 | 
			
		||||
				transition: opacity 50ms ease-in-out
 | 
			
		||||
				&.active
 | 
			
		||||
					visibility: visible
 | 
			
		||||
					opacity: 1
 | 
			
		||||
 | 
			
		||||
				.spinner
 | 
			
		||||
					color: $color-background-nav
 | 
			
		||||
					background-color: white
 | 
			
		||||
					padding: 0
 | 
			
		||||
					width: 20px
 | 
			
		||||
					height: 20px
 | 
			
		||||
					border-radius: 50%
 | 
			
		||||
					position: absolute
 | 
			
		||||
					top: 7px
 | 
			
		||||
					right: 10px
 | 
			
		||||
					span
 | 
			
		||||
						padding: 5px
 | 
			
		||||
						+pulse
 | 
			
		||||
 | 
			
		||||
	#search-details
 | 
			
		||||
		position: relative
 | 
			
		||||
		width: 40%
 | 
			
		||||
		border-left: 2px solid darken(white, 3%)
 | 
			
		||||
 | 
			
		||||
		#search-hit-container
 | 
			
		||||
			position: absolute // for scrollbars
 | 
			
		||||
			width: 100%
 | 
			
		||||
			overflow-y: auto
 | 
			
		||||
 | 
			
		||||
			#error_container
 | 
			
		||||
				position: relative
 | 
			
		||||
				background: white
 | 
			
		||||
				padding: 20px
 | 
			
		||||
 | 
			
		||||
		#search-error
 | 
			
		||||
			display: none
 | 
			
		||||
			margin: 20px auto
 | 
			
		||||
			color: $color-danger
 | 
			
		||||
			text-align: center
 | 
			
		||||
	#search-error
 | 
			
		||||
		display: none
 | 
			
		||||
		margin: 20px auto
 | 
			
		||||
		color: $color-danger
 | 
			
		||||
		text-align: center
 | 
			
		||||
 | 
			
		||||
#search-container
 | 
			
		||||
	#node-container
 | 
			
		||||
		width: 100%
 | 
			
		||||
		max-width: 100%
 | 
			
		||||
@@ -416,9 +231,7 @@ $search-hit-width_grid: 100px
 | 
			
		||||
 | 
			
		||||
		&.texture
 | 
			
		||||
			.texture-title
 | 
			
		||||
				font:
 | 
			
		||||
					size: 2em
 | 
			
		||||
					family: $font-body
 | 
			
		||||
				font-size: 2em
 | 
			
		||||
				padding: 15px 10px 10px 15px
 | 
			
		||||
			.node-row
 | 
			
		||||
				background: white
 | 
			
		||||
@@ -476,215 +289,118 @@ $search-hit-width_grid: 100px
 | 
			
		||||
								button
 | 
			
		||||
									width: 100%
 | 
			
		||||
 | 
			
		||||
.search-hit
 | 
			
		||||
	float: left
 | 
			
		||||
	box-shadow: none
 | 
			
		||||
	border: thin solid transparent
 | 
			
		||||
	border-top-color: darken(white, 8%)
 | 
			
		||||
	border-left: 3px solid transparent
 | 
			
		||||
#project_sidebar+#search-sidebar,
 | 
			
		||||
#project_sidebar+#search-sidebar+#search-container
 | 
			
		||||
	padding-left: $sidebar-width
 | 
			
		||||
 | 
			
		||||
	color: $color-background-nav
 | 
			
		||||
.search-project
 | 
			
		||||
	li.project
 | 
			
		||||
		display: none
 | 
			
		||||
 | 
			
		||||
	width: 100%
 | 
			
		||||
	position: relative
 | 
			
		||||
	margin: 0
 | 
			
		||||
	padding: 7px 10px 7px 10px
 | 
			
		||||
	+clearfix
 | 
			
		||||
 | 
			
		||||
	&:first-child
 | 
			
		||||
		border: thin solid transparent
 | 
			
		||||
		border-left: 3px solid transparent
 | 
			
		||||
 | 
			
		||||
	&:hover
 | 
			
		||||
		opacity: 1
 | 
			
		||||
		text-decoration: none
 | 
			
		||||
		cursor: default
 | 
			
		||||
		color: darken($color-primary, 20%)
 | 
			
		||||
		background-color: $color-background-light
 | 
			
		||||
 | 
			
		||||
		& .search-hit-name i
 | 
			
		||||
			color: darken($color-primary, 20%)
 | 
			
		||||
 | 
			
		||||
		& .search-hit-thumbnail
 | 
			
		||||
			& .search-hit-thumbnail-icon
 | 
			
		||||
				transform: translate(-50%, -50%) scale(1.1)
 | 
			
		||||
 | 
			
		||||
		.search-hit-name
 | 
			
		||||
			text-decoration: none
 | 
			
		||||
			&:hover
 | 
			
		||||
				color: darken($color-primary, 10%)
 | 
			
		||||
 | 
			
		||||
		.search-hit-thumbnail
 | 
			
		||||
			cursor: pointer
 | 
			
		||||
 | 
			
		||||
			.search-hit-thumbnail-icon
 | 
			
		||||
				transform: translate(-50%, -50%) scale(1)
 | 
			
		||||
 | 
			
		||||
	&:active
 | 
			
		||||
		background-color: rgba($color-background, .5)
 | 
			
		||||
		opacity: .8
 | 
			
		||||
		color: $color-primary
 | 
			
		||||
		& .search-hit-name i
 | 
			
		||||
			color: $color-primary
 | 
			
		||||
 | 
			
		||||
	&:focus
 | 
			
		||||
		border-color: rgba($color-primary, .2)
 | 
			
		||||
 | 
			
		||||
	/* Class that gets added when we click on the item */
 | 
			
		||||
	&.active
 | 
			
		||||
		background-color: lighten($color-background, 2%)
 | 
			
		||||
		border-left: 3px solid $color-primary
 | 
			
		||||
 | 
			
		||||
		.search-hit-name
 | 
			
		||||
			color: darken($color-primary, 10%)
 | 
			
		||||
 | 
			
		||||
		.search-hit-meta
 | 
			
		||||
			span.when
 | 
			
		||||
				display: none
 | 
			
		||||
			span.context
 | 
			
		||||
				display: inline-block
 | 
			
		||||
 | 
			
		||||
	.search-hit-thumbnail
 | 
			
		||||
		position: relative
 | 
			
		||||
		float: left
 | 
			
		||||
		min-width: $search-hit-width_list * 1.49
 | 
			
		||||
		max-width: $search-hit-width_list * 1.49
 | 
			
		||||
		height: $search-hit-width_list
 | 
			
		||||
#search-sidebar
 | 
			
		||||
	.card
 | 
			
		||||
		margin-bottom: 10px
 | 
			
		||||
		border-radius: 3px
 | 
			
		||||
		background: $color-background
 | 
			
		||||
		margin-right: 12px
 | 
			
		||||
		text-align: center
 | 
			
		||||
		overflow: hidden
 | 
			
		||||
		+media-xs
 | 
			
		||||
			display: none
 | 
			
		||||
		+media-sm
 | 
			
		||||
			min-width: $search-hit-width_list
 | 
			
		||||
			max-width: $search-hit-width_list
 | 
			
		||||
		border: none
 | 
			
		||||
		background-color: white
 | 
			
		||||
		box-shadow: 1px 1px 0 rgba(black, .1)
 | 
			
		||||
 | 
			
		||||
		img
 | 
			
		||||
			height: $search-hit-width_list
 | 
			
		||||
			width: auto
 | 
			
		||||
	a
 | 
			
		||||
		text-decoration: none
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		.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
 | 
			
		||||
	.toggleRefine
 | 
			
		||||
		display: block
 | 
			
		||||
		padding-left: 7px
 | 
			
		||||
		color: $color-text-dark
 | 
			
		||||
		text-transform: capitalize
 | 
			
		||||
 | 
			
		||||
		&:hover
 | 
			
		||||
			cursor: pointer
 | 
			
		||||
			text-decoration: underline
 | 
			
		||||
			text-decoration: none
 | 
			
		||||
			color: $primary
 | 
			
		||||
 | 
			
		||||
		em
 | 
			
		||||
			color: darken($color-primary, 15%)
 | 
			
		||||
			font-style: normal
 | 
			
		||||
		&.refined
 | 
			
		||||
			color: $primary
 | 
			
		||||
 | 
			
		||||
	.search-hit-ribbon
 | 
			
		||||
		+ribbon
 | 
			
		||||
		right: -30px
 | 
			
		||||
		top: 5px
 | 
			
		||||
			&:hover
 | 
			
		||||
				color: $color-danger
 | 
			
		||||
 | 
			
		||||
				span
 | 
			
		||||
					&:before
 | 
			
		||||
						/* x icon */
 | 
			
		||||
						content: '\e84b'
 | 
			
		||||
						font-family: 'pillar-font'
 | 
			
		||||
			span
 | 
			
		||||
				&:before
 | 
			
		||||
					/* circle with dot */
 | 
			
		||||
					content: '\e82f'
 | 
			
		||||
					font-family: 'pillar-font'
 | 
			
		||||
					position: relative
 | 
			
		||||
					left: -7px
 | 
			
		||||
					font-size: .9em
 | 
			
		||||
 | 
			
		||||
		span
 | 
			
		||||
			font-size: 60%
 | 
			
		||||
			margin: 1px 0
 | 
			
		||||
			padding: 2px 35px
 | 
			
		||||
 | 
			
		||||
	.search-hit-meta
 | 
			
		||||
		position: relative
 | 
			
		||||
		font-size: .9em
 | 
			
		||||
			&:before
 | 
			
		||||
				/* empty circle */
 | 
			
		||||
				content: '\e82c'
 | 
			
		||||
				font-family: 'pillar-font'
 | 
			
		||||
				position: relative
 | 
			
		||||
				left: -7px
 | 
			
		||||
				font-size: .9em
 | 
			
		||||
	.facet_count
 | 
			
		||||
		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
 | 
			
		||||
	.card-title
 | 
			
		||||
		position: relative
 | 
			
		||||
		&:after
 | 
			
		||||
			content: '\e83b'
 | 
			
		||||
			font-family: 'pillar-font'
 | 
			
		||||
			position: absolute
 | 
			
		||||
			right: 0
 | 
			
		||||
			color: $color-text-dark-primary
 | 
			
		||||
 | 
			
		||||
			&.when
 | 
			
		||||
				margin: 0 3px
 | 
			
		||||
				float: right
 | 
			
		||||
				display: block
 | 
			
		||||
				+media-lg
 | 
			
		||||
					display: block
 | 
			
		||||
				+media-md
 | 
			
		||||
					display: block
 | 
			
		||||
				+media-sm
 | 
			
		||||
					display: none
 | 
			
		||||
				+media-xs
 | 
			
		||||
					display: none
 | 
			
		||||
	.collapsed
 | 
			
		||||
		.card-title:after
 | 
			
		||||
			content: '\e838'
 | 
			
		||||
 | 
			
		||||
			&.context
 | 
			
		||||
				margin: 0
 | 
			
		||||
				float: right
 | 
			
		||||
				display: none
 | 
			
		||||
 | 
			
		||||
	&:hover
 | 
			
		||||
		cursor: pointer
 | 
			
		||||
		.search-hit-name-user
 | 
			
		||||
			color: $color-primary
 | 
			
		||||
	.search-list-stats
 | 
			
		||||
		color: $color-text-dark-hint
 | 
			
		||||
		padding: 10px 15px 0 15px
 | 
			
		||||
		text-align: center
 | 
			
		||||
		font-size: .9em
 | 
			
		||||
		+clearfix
 | 
			
		||||
 | 
			
		||||
	&.users
 | 
			
		||||
		em
 | 
			
		||||
			font-style: normal
 | 
			
		||||
			color: $color-primary
 | 
			
		||||
.search-pagination
 | 
			
		||||
	text-align: center
 | 
			
		||||
	list-style-type: none
 | 
			
		||||
	margin: 0
 | 
			
		||||
	padding: 0
 | 
			
		||||
	width: 100%
 | 
			
		||||
	display: flex
 | 
			
		||||
	+clearfix
 | 
			
		||||
 | 
			
		||||
		.search-hit-name
 | 
			
		||||
			font-size: 1.2em
 | 
			
		||||
	li
 | 
			
		||||
		display: inline-block
 | 
			
		||||
		margin: 5px auto
 | 
			
		||||
 | 
			
		||||
			small
 | 
			
		||||
				margin-left: 5px
 | 
			
		||||
				color: $color-text-dark-secondary
 | 
			
		||||
		&:last-child
 | 
			
		||||
			border-color: transparent
 | 
			
		||||
 | 
			
		||||
		.search-hit-roles
 | 
			
		||||
			font-size: .9em
 | 
			
		||||
		a
 | 
			
		||||
			font-weight: 500
 | 
			
		||||
			padding: 5px 4px
 | 
			
		||||
			color: $color-text-dark-secondary
 | 
			
		||||
			margin-left: 15px
 | 
			
		||||
 | 
			
		||||
			&:hover
 | 
			
		||||
				color: $color-text-dark-primary
 | 
			
		||||
 | 
			
		||||
		&.disabled
 | 
			
		||||
			opacity: .6
 | 
			
		||||
 | 
			
		||||
		&.active a
 | 
			
		||||
			color: $color-text-dark-primary
 | 
			
		||||
			font-weight: bold
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
.view-grid
 | 
			
		||||
	display: flex
 | 
			
		||||
@@ -706,13 +422,13 @@ $search-hit-width_grid: 100px
 | 
			
		||||
		transition: border-color 150ms ease-in-out
 | 
			
		||||
 | 
			
		||||
		&.active
 | 
			
		||||
			background-color: $color-primary
 | 
			
		||||
			border-color: $color-primary
 | 
			
		||||
			background-color: $primary
 | 
			
		||||
			border-color: $primary
 | 
			
		||||
 | 
			
		||||
			.search-hit-name
 | 
			
		||||
				font-weight: 500
 | 
			
		||||
				color: white
 | 
			
		||||
				background-color: $color-primary
 | 
			
		||||
				background-color: $primary
 | 
			
		||||
 | 
			
		||||
		.search-hit-name
 | 
			
		||||
			font-size: .9em
 | 
			
		||||
@@ -776,5 +492,5 @@ $search-hit-width_grid: 100px
 | 
			
		||||
 | 
			
		||||
			&.active
 | 
			
		||||
				color: white
 | 
			
		||||
				background-color: $color-primary
 | 
			
		||||
				background-color: $primary
 | 
			
		||||
				border-color: transparent
 | 
			
		||||
 
 | 
			
		||||
@@ -67,138 +67,6 @@
 | 
			
		||||
        &:hover
 | 
			
		||||
          background-color: lighten($provider-color-google, 7%)
 | 
			
		||||
 | 
			
		||||
#settings
 | 
			
		||||
  +media-xs
 | 
			
		||||
    flex-direction: column
 | 
			
		||||
 | 
			
		||||
  align-items: stretch
 | 
			
		||||
  display: flex
 | 
			
		||||
  margin: 25px auto
 | 
			
		||||
 | 
			
		||||
  #settings-sidebar
 | 
			
		||||
    +media-xs
 | 
			
		||||
      width: 100%
 | 
			
		||||
 | 
			
		||||
    +container-box
 | 
			
		||||
    background-color: $color-background-light
 | 
			
		||||
    color: $color-text
 | 
			
		||||
    margin-right: 15px
 | 
			
		||||
    width: 30%
 | 
			
		||||
 | 
			
		||||
    .settings-content
 | 
			
		||||
      padding: 0
 | 
			
		||||
 | 
			
		||||
      ul
 | 
			
		||||
        list-style: none
 | 
			
		||||
        margin: 0
 | 
			
		||||
        padding: 0
 | 
			
		||||
 | 
			
		||||
      a
 | 
			
		||||
        &:hover
 | 
			
		||||
          text-decoration: none
 | 
			
		||||
 | 
			
		||||
          li
 | 
			
		||||
            background-color: lighten($color-background, 5%)
 | 
			
		||||
 | 
			
		||||
        li
 | 
			
		||||
          border-bottom: thin solid $color-background
 | 
			
		||||
          border-left: thick solid transparent
 | 
			
		||||
          margin: 0
 | 
			
		||||
          padding: 25px
 | 
			
		||||
          transition: all 100ms ease-in-out
 | 
			
		||||
 | 
			
		||||
          i
 | 
			
		||||
            font-size: 1.1em
 | 
			
		||||
            padding-right: 15px
 | 
			
		||||
 | 
			
		||||
      .active
 | 
			
		||||
        li
 | 
			
		||||
          background-color: lighten($color-background, 5%)
 | 
			
		||||
          border-left: thick solid $color-info
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  #settings-container
 | 
			
		||||
    +media-xs
 | 
			
		||||
      width: 100%
 | 
			
		||||
 | 
			
		||||
    +container-box
 | 
			
		||||
    background-color: $color-background-light
 | 
			
		||||
    width: 70%
 | 
			
		||||
 | 
			
		||||
  .settings-header
 | 
			
		||||
    background-color: $color-background
 | 
			
		||||
    border-top-left-radius: 3px
 | 
			
		||||
    border-top-right-radius: 3px
 | 
			
		||||
 | 
			
		||||
    .settings-title
 | 
			
		||||
      font:
 | 
			
		||||
        size: 1.5em
 | 
			
		||||
        weight: 300
 | 
			
		||||
      padding: 10px 15px 10px 25px
 | 
			
		||||
 | 
			
		||||
  .settings-content
 | 
			
		||||
    padding: 25px
 | 
			
		||||
 | 
			
		||||
    .settings-billing-info
 | 
			
		||||
      font-size: 1.2em
 | 
			
		||||
 | 
			
		||||
    .subscription-active
 | 
			
		||||
      color: $color-success
 | 
			
		||||
      padding-bottom: 20px
 | 
			
		||||
    .subscription-demo
 | 
			
		||||
      color: $color-info
 | 
			
		||||
      margin-top: 0
 | 
			
		||||
    .subscription-missing
 | 
			
		||||
      color: $color-danger
 | 
			
		||||
      margin-top: 0
 | 
			
		||||
 | 
			
		||||
  .button-submit
 | 
			
		||||
    clear: both
 | 
			
		||||
    display: block
 | 
			
		||||
    min-width: 200px
 | 
			
		||||
    margin: 0 auto
 | 
			
		||||
    +button($color-primary, 3px, true)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
#settings-container
 | 
			
		||||
  #settings-form
 | 
			
		||||
    width: 100%
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  .settings-form
 | 
			
		||||
    align-items: center
 | 
			
		||||
    display: flex
 | 
			
		||||
    justify-content: center
 | 
			
		||||
 | 
			
		||||
    .left, .right
 | 
			
		||||
      padding: 25px 0
 | 
			
		||||
 | 
			
		||||
    .left
 | 
			
		||||
      width: 60%
 | 
			
		||||
      float: left
 | 
			
		||||
 | 
			
		||||
    .right
 | 
			
		||||
      width: 40%
 | 
			
		||||
      float: right
 | 
			
		||||
      text-align: center
 | 
			
		||||
 | 
			
		||||
    label
 | 
			
		||||
      color: $color-text
 | 
			
		||||
      display: block
 | 
			
		||||
 | 
			
		||||
    .settings-avatar
 | 
			
		||||
      img
 | 
			
		||||
        border-radius: 3px
 | 
			
		||||
 | 
			
		||||
      span
 | 
			
		||||
        display: block
 | 
			
		||||
        padding: 15px 0
 | 
			
		||||
        font:
 | 
			
		||||
          size: .9em
 | 
			
		||||
 | 
			
		||||
    .settings-password
 | 
			
		||||
      color: $color-text-dark-primary
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
#user-edit-container
 | 
			
		||||
  padding: 15px
 | 
			
		||||
 
 | 
			
		||||
@@ -26,7 +26,6 @@
 | 
			
		||||
	display: inline-flex
 | 
			
		||||
	align-items: center
 | 
			
		||||
	justify-content: center
 | 
			
		||||
	font-family: $font-body
 | 
			
		||||
	padding: 5px 12px
 | 
			
		||||
	border-radius: $roundness
 | 
			
		||||
 | 
			
		||||
@@ -83,6 +82,15 @@
 | 
			
		||||
			text-shadow: none
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
=disabled-stripes
 | 
			
		||||
	color: $color-text-dark
 | 
			
		||||
	cursor: not-allowed
 | 
			
		||||
	background: repeating-linear-gradient(-45deg, lighten($color-text-dark-hint, 15%), lighten($color-text-dark-hint, 15%) 10px, lighten($color-text-dark-hint, 5%) 10px, lighten($color-text-dark-hint, 5%) 20px)
 | 
			
		||||
	border-color: darken($color-text-dark-hint, 5%)
 | 
			
		||||
	pointer-events: none
 | 
			
		||||
	opacity: .6
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@mixin overlay($from-color, $from-percentage, $to-color, $to-percentage)
 | 
			
		||||
	position: absolute
 | 
			
		||||
	top: 0
 | 
			
		||||
@@ -122,24 +130,17 @@
 | 
			
		||||
	transform: translate(-50%, -50%)
 | 
			
		||||
 | 
			
		||||
=input-generic
 | 
			
		||||
	padding: 5px 5px 5px 0
 | 
			
		||||
	// padding: 5px 5px 5px 0
 | 
			
		||||
	color: $color-text-dark
 | 
			
		||||
	box-shadow: none
 | 
			
		||||
	font-family: $font-body
 | 
			
		||||
	border: thin solid transparent
 | 
			
		||||
	border-radius: 0
 | 
			
		||||
	border-bottom-color: $color-background-dark
 | 
			
		||||
	background-color: transparent
 | 
			
		||||
	transition: border-color 150ms ease-in-out, box-shadow 150ms ease-in-out
 | 
			
		||||
 | 
			
		||||
	&:hover
 | 
			
		||||
		border-bottom-color: $color-background
 | 
			
		||||
 | 
			
		||||
	&:focus
 | 
			
		||||
		outline: 0
 | 
			
		||||
		border: thin solid transparent
 | 
			
		||||
		border-bottom-color: $color-primary
 | 
			
		||||
		box-shadow: 0 1px 0 0 $color-primary
 | 
			
		||||
		border-color: $primary
 | 
			
		||||
		box-shadow: none
 | 
			
		||||
 | 
			
		||||
=label-generic
 | 
			
		||||
	color: $color-text-dark-primary
 | 
			
		||||
@@ -170,17 +171,25 @@
 | 
			
		||||
/* Small but wide: phablets, iPads
 | 
			
		||||
 **  Menu is collapsed, columns stack, no brand */
 | 
			
		||||
=media-sm
 | 
			
		||||
	@media (min-width: #{$screen-tablet}) and (max-width: #{$screen-desktop - 1px})
 | 
			
		||||
	@include media-breakpoint-up(sm)
 | 
			
		||||
		@content
 | 
			
		||||
 | 
			
		||||
/* Tablets portrait.
 | 
			
		||||
 **  Menu is expanded, but columns stack, brand is shown */
 | 
			
		||||
=media-md
 | 
			
		||||
	@media (min-width: #{$screen-desktop})
 | 
			
		||||
	@include media-breakpoint-up(md)
 | 
			
		||||
		@content
 | 
			
		||||
 | 
			
		||||
=media-lg
 | 
			
		||||
	@media (min-width: #{$screen-lg-desktop})
 | 
			
		||||
	@include media-breakpoint-up(lg)
 | 
			
		||||
		@content
 | 
			
		||||
 | 
			
		||||
=media-xl
 | 
			
		||||
	@include media-breakpoint-up(xl)
 | 
			
		||||
		@content
 | 
			
		||||
 | 
			
		||||
=media-xxl
 | 
			
		||||
	@include media-breakpoint-up(xxl)
 | 
			
		||||
		@content
 | 
			
		||||
 | 
			
		||||
=media-print
 | 
			
		||||
@@ -354,7 +363,6 @@
 | 
			
		||||
	+clearfix
 | 
			
		||||
	color: darken($color-text-dark, 5%)
 | 
			
		||||
	font:
 | 
			
		||||
		family: $font-body
 | 
			
		||||
		weight: 300
 | 
			
		||||
		size: 1.2em
 | 
			
		||||
 | 
			
		||||
@@ -405,6 +413,10 @@
 | 
			
		||||
			bottom: 25px
 | 
			
		||||
			top: 25px
 | 
			
		||||
 | 
			
		||||
		&.emoji
 | 
			
		||||
			display: inline-block
 | 
			
		||||
			padding: initial
 | 
			
		||||
 | 
			
		||||
	h2
 | 
			
		||||
		margin-bottom: 15px
 | 
			
		||||
 | 
			
		||||
@@ -443,11 +455,12 @@
 | 
			
		||||
 | 
			
		||||
		li
 | 
			
		||||
			margin-bottom: 7px
 | 
			
		||||
 | 
			
		||||
			img
 | 
			
		||||
				display: block
 | 
			
		||||
				padding:
 | 
			
		||||
					top: 25px
 | 
			
		||||
					bottom: 10px
 | 
			
		||||
					top: 25px
 | 
			
		||||
 | 
			
		||||
			ul, ul li ul
 | 
			
		||||
				margin-top: 15px
 | 
			
		||||
@@ -455,10 +468,13 @@
 | 
			
		||||
 | 
			
		||||
	code, kbd, pre, samp
 | 
			
		||||
		background-color: rgba($color-primary, .05)
 | 
			
		||||
		color: $color-primary
 | 
			
		||||
		color: darken($color-primary, 15%)
 | 
			
		||||
		font-size: inherit
 | 
			
		||||
		white-space: pre-line
 | 
			
		||||
 | 
			
		||||
		code
 | 
			
		||||
			background-color: transparent
 | 
			
		||||
 | 
			
		||||
	kbd
 | 
			
		||||
		border:
 | 
			
		||||
			color: rgba($color-primary, .33)
 | 
			
		||||
@@ -499,28 +515,22 @@
 | 
			
		||||
 | 
			
		||||
=ribbon
 | 
			
		||||
	background-color: $color-success
 | 
			
		||||
	cursor: default
 | 
			
		||||
	border: thin dashed rgba(white, .5)
 | 
			
		||||
	color: white
 | 
			
		||||
	pointer-events: none
 | 
			
		||||
	font-size: 70%
 | 
			
		||||
	overflow: hidden
 | 
			
		||||
	white-space: nowrap
 | 
			
		||||
	position: absolute
 | 
			
		||||
	right: -40px
 | 
			
		||||
	top: 10px
 | 
			
		||||
	-webkit-transform: rotate(45deg)
 | 
			
		||||
	-moz-transform: rotate(45deg)
 | 
			
		||||
	-ms-transform: rotate(45deg)
 | 
			
		||||
	-o-transform: rotate(45deg)
 | 
			
		||||
	transform: rotate(45deg)
 | 
			
		||||
	white-space: nowrap
 | 
			
		||||
 | 
			
		||||
	span
 | 
			
		||||
		border: thin dashed rgba(white, .5)
 | 
			
		||||
		color: white
 | 
			
		||||
		display: block
 | 
			
		||||
		font-size: 70%
 | 
			
		||||
		margin: 1px 0
 | 
			
		||||
		padding: 3px 50px
 | 
			
		||||
		text:
 | 
			
		||||
			align: center
 | 
			
		||||
			transform: uppercase
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@mixin text-background($text-color, $background-color, $roundness, $padding)
 | 
			
		||||
	border-radius: $roundness
 | 
			
		||||
@@ -634,9 +644,7 @@
 | 
			
		||||
				#{$property}: $color-status-review
 | 
			
		||||
 | 
			
		||||
=sidebar-button-active
 | 
			
		||||
	background-color: $color-background-nav
 | 
			
		||||
	box-shadow: inset 2px 0 0 $color-primary
 | 
			
		||||
	color: white
 | 
			
		||||
	color: $primary
 | 
			
		||||
 | 
			
		||||
.flash-on
 | 
			
		||||
	background-color: lighten($color-success, 50%) !important
 | 
			
		||||
@@ -652,3 +660,35 @@
 | 
			
		||||
	transition: all 1s ease-out
 | 
			
		||||
	img
 | 
			
		||||
		transition: all 1s ease-out
 | 
			
		||||
 | 
			
		||||
.cursor-pointer
 | 
			
		||||
	cursor: pointer
 | 
			
		||||
 | 
			
		||||
.user-select-none
 | 
			
		||||
	user-select: none
 | 
			
		||||
 | 
			
		||||
.pointer-events-none
 | 
			
		||||
	pointer-events: none
 | 
			
		||||
 | 
			
		||||
// Bootstrap has .img-fluid, a class to limit the width of an image to 100%.
 | 
			
		||||
// .imgs-fluid below is to be applied on a parent container when we can't add
 | 
			
		||||
// classes to the images themselves. e.g. the blog.
 | 
			
		||||
.imgs-fluid
 | 
			
		||||
	img
 | 
			
		||||
		// Just re-use Bootstrap's mixin here.
 | 
			
		||||
		+img-fluid
 | 
			
		||||
 | 
			
		||||
.overflow-hidden
 | 
			
		||||
	overflow: hidden
 | 
			
		||||
 | 
			
		||||
=text-gradient($color_from, $color_to)
 | 
			
		||||
		background: linear-gradient(to right, $color_from, $color_to)
 | 
			
		||||
		background-clip: text
 | 
			
		||||
		-webkit-background-clip: text
 | 
			
		||||
		-webkit-text-fill-color: transparent
 | 
			
		||||
 | 
			
		||||
=active-gradient
 | 
			
		||||
	+text-gradient($primary-accent, $primary)
 | 
			
		||||
 | 
			
		||||
	&:before
 | 
			
		||||
		+text-gradient($primary-accent, $primary)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1290
									
								
								src/styles/base.sass
									
									
									
									
									
								
							
							
						
						
									
										1290
									
								
								src/styles/base.sass
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -1,33 +1,58 @@
 | 
			
		||||
@import _normalize
 | 
			
		||||
// Bootstrap variables and utilities.
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/functions"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/variables"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/mixins"
 | 
			
		||||
 | 
			
		||||
@import _config
 | 
			
		||||
@import _utils
 | 
			
		||||
 | 
			
		||||
// Bootstrap components.
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/root"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/reboot"
 | 
			
		||||
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/type"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/images"
 | 
			
		||||
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/code"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/grid"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/buttons"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/dropdown"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/custom-forms"
 | 
			
		||||
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/nav"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/navbar"
 | 
			
		||||
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/card"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/jumbotron"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/media"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/close"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/modal"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/tooltip"
 | 
			
		||||
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/utilities"
 | 
			
		||||
 | 
			
		||||
// Pillar components.
 | 
			
		||||
@import "apps_base"
 | 
			
		||||
@import "components/base"
 | 
			
		||||
 | 
			
		||||
@import "components/card"
 | 
			
		||||
@import "components/jumbotron"
 | 
			
		||||
@import "components/navbar"
 | 
			
		||||
@import "components/dropdown"
 | 
			
		||||
@import "components/footer"
 | 
			
		||||
@import "components/shortcode"
 | 
			
		||||
@import "components/flyout"
 | 
			
		||||
@import "components/buttons"
 | 
			
		||||
@import "components/tooltip"
 | 
			
		||||
@import "components/overlay"
 | 
			
		||||
 | 
			
		||||
@import _comments
 | 
			
		||||
@import _error
 | 
			
		||||
@import _search
 | 
			
		||||
 | 
			
		||||
.container-fluid.blog
 | 
			
		||||
	padding: 0
 | 
			
		||||
 | 
			
		||||
#blog_container
 | 
			
		||||
	+media-xs
 | 
			
		||||
		flex-direction: column
 | 
			
		||||
		padding-top: 0
 | 
			
		||||
	display: flex
 | 
			
		||||
	padding:
 | 
			
		||||
		bottom: 15px
 | 
			
		||||
 | 
			
		||||
	video
 | 
			
		||||
		max-width: 100%
 | 
			
		||||
@import _notifications
 | 
			
		||||
 | 
			
		||||
#blog_post-edit-form
 | 
			
		||||
	padding: 20px
 | 
			
		||||
 | 
			
		||||
	.form-group
 | 
			
		||||
		position: relative
 | 
			
		||||
		margin: 0 auto 30px auto
 | 
			
		||||
		font-family: $font-body
 | 
			
		||||
 | 
			
		||||
		input, textarea, select
 | 
			
		||||
			+input-generic
 | 
			
		||||
 | 
			
		||||
@@ -95,7 +120,6 @@
 | 
			
		||||
					margin-bottom: 15px
 | 
			
		||||
					border-top: thin solid $color-text-dark-hint
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
	.form-group.description,
 | 
			
		||||
	.form-group.summary,
 | 
			
		||||
	.form-group.content
 | 
			
		||||
@@ -163,14 +187,10 @@
 | 
			
		||||
					color: transparent
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
#blog_post-create-container,
 | 
			
		||||
#blog_post-edit-container
 | 
			
		||||
	padding: 25px
 | 
			
		||||
 | 
			
		||||
#blog_index-container,
 | 
			
		||||
#blog_post-create-container,
 | 
			
		||||
#blog_post-edit-container
 | 
			
		||||
	+container-box
 | 
			
		||||
	padding: 25px
 | 
			
		||||
	width: 75%
 | 
			
		||||
 | 
			
		||||
	+media-xs
 | 
			
		||||
@@ -185,133 +205,6 @@
 | 
			
		||||
	+media-lg
 | 
			
		||||
		width: 100%
 | 
			
		||||
 | 
			
		||||
	.blog_index-header
 | 
			
		||||
		border-top-left-radius: 3px
 | 
			
		||||
		border-top-right-radius: 3px
 | 
			
		||||
		display: block
 | 
			
		||||
		overflow: hidden
 | 
			
		||||
		position: relative
 | 
			
		||||
		text-align: center
 | 
			
		||||
		width: 100%
 | 
			
		||||
 | 
			
		||||
		img
 | 
			
		||||
			width: 100%
 | 
			
		||||
 | 
			
		||||
	.blog_index-item
 | 
			
		||||
		+media-lg
 | 
			
		||||
			max-width: 780px
 | 
			
		||||
		+media-md
 | 
			
		||||
			max-width: 780px
 | 
			
		||||
		+media-sm
 | 
			
		||||
			max-width: 780px
 | 
			
		||||
 | 
			
		||||
		margin: 15px auto
 | 
			
		||||
 | 
			
		||||
		&:hover
 | 
			
		||||
			.item-info a
 | 
			
		||||
				color: $color-primary
 | 
			
		||||
 | 
			
		||||
		.item-picture
 | 
			
		||||
			position: relative
 | 
			
		||||
			width: 100%
 | 
			
		||||
			max-height: 350px
 | 
			
		||||
			min-height: 200px
 | 
			
		||||
			height: auto
 | 
			
		||||
			overflow: hidden
 | 
			
		||||
			border-top-left-radius: 3px
 | 
			
		||||
			border-top-right-radius: 3px
 | 
			
		||||
			+clearfix
 | 
			
		||||
 | 
			
		||||
			img
 | 
			
		||||
				+position-center-translate
 | 
			
		||||
				width: 100%
 | 
			
		||||
				border-top-left-radius: 3px
 | 
			
		||||
				border-top-right-radius: 3px
 | 
			
		||||
 | 
			
		||||
			+media-xs
 | 
			
		||||
				min-height: 150px
 | 
			
		||||
			+media-sm
 | 
			
		||||
				min-height: 150px
 | 
			
		||||
			+media-md
 | 
			
		||||
				min-height: 250px
 | 
			
		||||
			+media-lg
 | 
			
		||||
				min-height: 250px
 | 
			
		||||
 | 
			
		||||
		.item-title
 | 
			
		||||
			color: $color-text-dark
 | 
			
		||||
			display: block
 | 
			
		||||
			font:
 | 
			
		||||
				family: $font-body
 | 
			
		||||
				size: 1.8em
 | 
			
		||||
 | 
			
		||||
			padding: 10px 25px 10px
 | 
			
		||||
 | 
			
		||||
		ul.meta
 | 
			
		||||
			+list-meta
 | 
			
		||||
			font-size: .9em
 | 
			
		||||
			padding: 0px 25px 5px
 | 
			
		||||
 | 
			
		||||
		.item-content
 | 
			
		||||
			+node-details-description
 | 
			
		||||
			font-size: 1.3em
 | 
			
		||||
			padding: 15px 25px 25px
 | 
			
		||||
 | 
			
		||||
			+media-xs
 | 
			
		||||
				padding:
 | 
			
		||||
					left: 0
 | 
			
		||||
					right: 0
 | 
			
		||||
 | 
			
		||||
			img
 | 
			
		||||
				display: block
 | 
			
		||||
				margin: 0 auto
 | 
			
		||||
 | 
			
		||||
		.item-meta
 | 
			
		||||
			color: $color-text-dark-secondary
 | 
			
		||||
			padding:
 | 
			
		||||
				left: 25px
 | 
			
		||||
				right: 25px
 | 
			
		||||
 | 
			
		||||
			+media-xs
 | 
			
		||||
				padding:
 | 
			
		||||
					left: 10px
 | 
			
		||||
					right: 10px
 | 
			
		||||
 | 
			
		||||
	.button-create,
 | 
			
		||||
	.button-edit
 | 
			
		||||
		+button($color-success, 3px, true)
 | 
			
		||||
 | 
			
		||||
	.item-picture+.button-back+.button-edit
 | 
			
		||||
		right: 20px
 | 
			
		||||
		top: 20px
 | 
			
		||||
 | 
			
		||||
	.comments-container
 | 
			
		||||
		padding:
 | 
			
		||||
			left: 20px
 | 
			
		||||
			right: 20px
 | 
			
		||||
		max-width: 680px
 | 
			
		||||
		margin: 0 auto
 | 
			
		||||
 | 
			
		||||
		+media-lg
 | 
			
		||||
			padding:
 | 
			
		||||
				left: 0
 | 
			
		||||
				right: 0
 | 
			
		||||
 | 
			
		||||
		.comment-reply-container
 | 
			
		||||
			background-color: transparent
 | 
			
		||||
 | 
			
		||||
			.comment-reply-field
 | 
			
		||||
				textarea, .comment-reply-meta
 | 
			
		||||
					background-color: $color-background-light
 | 
			
		||||
 | 
			
		||||
				&.filled
 | 
			
		||||
					.comment-reply-meta
 | 
			
		||||
						background-color: $color-success
 | 
			
		||||
 | 
			
		||||
		.comment-reply-form
 | 
			
		||||
			+media-xs
 | 
			
		||||
				padding:
 | 
			
		||||
					left: 0
 | 
			
		||||
 | 
			
		||||
#blog_post-edit-form
 | 
			
		||||
	padding: 0
 | 
			
		||||
 | 
			
		||||
@@ -346,294 +239,3 @@
 | 
			
		||||
 | 
			
		||||
	.form-upload-file-meta
 | 
			
		||||
		width: initial
 | 
			
		||||
 | 
			
		||||
#blog_post-edit-title
 | 
			
		||||
	padding: 0
 | 
			
		||||
	color: $color-text
 | 
			
		||||
	font:
 | 
			
		||||
		size: 1.8em
 | 
			
		||||
		weight: 300
 | 
			
		||||
	margin: 0 20px 15px 0
 | 
			
		||||
 | 
			
		||||
#blog_index-sidebar
 | 
			
		||||
	width: 25%
 | 
			
		||||
	padding: 0 15px
 | 
			
		||||
 | 
			
		||||
	+media-xs
 | 
			
		||||
		width: 100%
 | 
			
		||||
		clear: both
 | 
			
		||||
		display: block
 | 
			
		||||
		margin-top: 25px
 | 
			
		||||
	+media-sm
 | 
			
		||||
		width: 40%
 | 
			
		||||
	+media-md
 | 
			
		||||
		width: 30%
 | 
			
		||||
	+media-lg
 | 
			
		||||
		width: 25%
 | 
			
		||||
 | 
			
		||||
	.button-create
 | 
			
		||||
		display: block
 | 
			
		||||
		width: 100%
 | 
			
		||||
		+button($color-success, 6px)
 | 
			
		||||
		margin: 0
 | 
			
		||||
 | 
			
		||||
	.button-back
 | 
			
		||||
		+button($color-info, 6px, true)
 | 
			
		||||
		display: block
 | 
			
		||||
		width: 100%
 | 
			
		||||
		margin: 15px 0 0 0
 | 
			
		||||
 | 
			
		||||
	#blog_post-edit-form
 | 
			
		||||
		.form-group
 | 
			
		||||
			.form-control
 | 
			
		||||
				background-color: white
 | 
			
		||||
 | 
			
		||||
	.blog_index-sidebar,
 | 
			
		||||
	.blog_project-sidebar
 | 
			
		||||
		+container-box
 | 
			
		||||
		background-color: lighten($color-background, 5%)
 | 
			
		||||
		padding: 20px
 | 
			
		||||
 | 
			
		||||
	.blog_project-card
 | 
			
		||||
		position: relative
 | 
			
		||||
		width: 100%
 | 
			
		||||
		border-radius: 3px
 | 
			
		||||
		overflow: hidden
 | 
			
		||||
		background-color: white
 | 
			
		||||
		color: lighten($color-text, 10%)
 | 
			
		||||
		box-shadow: 0 0 30px rgba(black, .2)
 | 
			
		||||
 | 
			
		||||
		margin:
 | 
			
		||||
			top: 0
 | 
			
		||||
			bottom: 15px
 | 
			
		||||
			left: auto
 | 
			
		||||
			right: auto
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		a.item-header
 | 
			
		||||
			position: relative
 | 
			
		||||
			width: 100%
 | 
			
		||||
			height: 100px
 | 
			
		||||
			display: block
 | 
			
		||||
			background-size: 100% 100%
 | 
			
		||||
 | 
			
		||||
			overflow: hidden
 | 
			
		||||
 | 
			
		||||
			.overlay
 | 
			
		||||
				z-index: 1
 | 
			
		||||
				width: 100%
 | 
			
		||||
				height: 100px
 | 
			
		||||
				@include overlay(transparent, 0%, white, 100%)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
			img.background
 | 
			
		||||
				width: 100%
 | 
			
		||||
				transform: scale(1.4)
 | 
			
		||||
 | 
			
		||||
		.card-thumbnail
 | 
			
		||||
			position: absolute
 | 
			
		||||
			z-index: 2
 | 
			
		||||
			height: 90px
 | 
			
		||||
			width: 90px
 | 
			
		||||
			display: block
 | 
			
		||||
			top: 35px
 | 
			
		||||
			left: 50%
 | 
			
		||||
			transform: translateX(-50%)
 | 
			
		||||
			background-color: white
 | 
			
		||||
			border-radius: 3px
 | 
			
		||||
			overflow: hidden
 | 
			
		||||
 | 
			
		||||
			&:hover
 | 
			
		||||
				img.thumb
 | 
			
		||||
					opacity: .9
 | 
			
		||||
 | 
			
		||||
			img.thumb
 | 
			
		||||
				width: 100%
 | 
			
		||||
				border-radius: 3px
 | 
			
		||||
				transition: opacity 150ms ease-in-out
 | 
			
		||||
				+position-center-translate
 | 
			
		||||
 | 
			
		||||
		.item-info
 | 
			
		||||
			padding: 10px 20px
 | 
			
		||||
			background-color: white
 | 
			
		||||
			border-bottom-left-radius: 3px
 | 
			
		||||
			border-bottom-right-radius: 3px
 | 
			
		||||
 | 
			
		||||
			a.item-title
 | 
			
		||||
				display: inline-block
 | 
			
		||||
				width: 100%
 | 
			
		||||
				padding: 30px 0 15px 0
 | 
			
		||||
				color: $color-text-dark
 | 
			
		||||
				text-align: center
 | 
			
		||||
				font:
 | 
			
		||||
					size: 1.6em
 | 
			
		||||
					weight: 300
 | 
			
		||||
 | 
			
		||||
				transition: color 150ms ease-in-out
 | 
			
		||||
 | 
			
		||||
				&:hover
 | 
			
		||||
					text-decoration: none
 | 
			
		||||
					color: $color-primary
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
#blog_container
 | 
			
		||||
	&.cloud-blog
 | 
			
		||||
		#blog_index-container,
 | 
			
		||||
		#blog_post-create-container,
 | 
			
		||||
		#blog_post-edit-container
 | 
			
		||||
			width: 100%
 | 
			
		||||
			padding: 25px 30px 20px 30px
 | 
			
		||||
 | 
			
		||||
		#blog_index-container+#blog_index-sidebar
 | 
			
		||||
			display: none
 | 
			
		||||
 | 
			
		||||
	#blog_index-container,
 | 
			
		||||
	&.cloud-blog #blog_index-container
 | 
			
		||||
		+media-sm
 | 
			
		||||
			width: 100%
 | 
			
		||||
 | 
			
		||||
		+media-xs
 | 
			
		||||
			width: 100%
 | 
			
		||||
 | 
			
		||||
		padding: 0 0 50px 0
 | 
			
		||||
		margin: 0 auto
 | 
			
		||||
 | 
			
		||||
		.blog_index-item
 | 
			
		||||
			+media-xs
 | 
			
		||||
				width: 100%
 | 
			
		||||
				padding: 10px 25px
 | 
			
		||||
 | 
			
		||||
			&.list
 | 
			
		||||
				margin: 0 auto
 | 
			
		||||
				padding: 15px 0
 | 
			
		||||
				margin: 0 auto
 | 
			
		||||
				border-bottom: thin solid $color-background
 | 
			
		||||
 | 
			
		||||
				&:last-child
 | 
			
		||||
					border-bottom: none
 | 
			
		||||
 | 
			
		||||
				+media-xs
 | 
			
		||||
					width: 100%
 | 
			
		||||
					padding: 15px 10px
 | 
			
		||||
					margin: 0
 | 
			
		||||
 | 
			
		||||
				a.item-title
 | 
			
		||||
					padding:
 | 
			
		||||
						top: 0
 | 
			
		||||
						bottom: 5px
 | 
			
		||||
					font:
 | 
			
		||||
						size: 1.6em
 | 
			
		||||
						weight: 400
 | 
			
		||||
						family: $font-body
 | 
			
		||||
 | 
			
		||||
				.item-info
 | 
			
		||||
					color: $color-text-dark-secondary
 | 
			
		||||
					font-size: .9em
 | 
			
		||||
					padding:
 | 
			
		||||
						left: 25px
 | 
			
		||||
						right: 25px
 | 
			
		||||
 | 
			
		||||
				.item-header
 | 
			
		||||
					width: 50px
 | 
			
		||||
					height: 50px
 | 
			
		||||
					position: absolute
 | 
			
		||||
					top: 20px
 | 
			
		||||
					border-radius: 3px
 | 
			
		||||
					background-color: $color-background
 | 
			
		||||
					overflow: hidden
 | 
			
		||||
 | 
			
		||||
					img
 | 
			
		||||
						+position-center-translate
 | 
			
		||||
						width: 100%
 | 
			
		||||
 | 
			
		||||
					i
 | 
			
		||||
						+position-center-translate
 | 
			
		||||
						font-size: 1.2em
 | 
			
		||||
						color: $color-text-dark-hint
 | 
			
		||||
 | 
			
		||||
					&.nothumb
 | 
			
		||||
						border-radius: 50%
 | 
			
		||||
 | 
			
		||||
				a.item-title, .item-info
 | 
			
		||||
					padding-left: 70px
 | 
			
		||||
 | 
			
		||||
#blog_index-container
 | 
			
		||||
	.blog_index-item
 | 
			
		||||
		position: relative
 | 
			
		||||
 | 
			
		||||
		+media-xs
 | 
			
		||||
			padding: 25px 0 20px 0
 | 
			
		||||
 | 
			
		||||
		&.list
 | 
			
		||||
			padding: 15px 10px
 | 
			
		||||
			margin: 0
 | 
			
		||||
 | 
			
		||||
			+media-xs
 | 
			
		||||
				width: 100%
 | 
			
		||||
				padding: 15px 10px
 | 
			
		||||
				margin: 0
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
.blog-archive-navigation
 | 
			
		||||
	+media-xs
 | 
			
		||||
		font-size: 1em
 | 
			
		||||
		max-width: initial
 | 
			
		||||
 | 
			
		||||
	border-bottom: thin solid $color-background-dark
 | 
			
		||||
	display: flex
 | 
			
		||||
	font:
 | 
			
		||||
		size: 1.2em
 | 
			
		||||
		weight: 300
 | 
			
		||||
	margin: 0 auto
 | 
			
		||||
	max-width: 780px
 | 
			
		||||
	text-align: center
 | 
			
		||||
	+text-overflow-ellipsis
 | 
			
		||||
 | 
			
		||||
	&:last-child
 | 
			
		||||
		border: none
 | 
			
		||||
 | 
			
		||||
	a, span
 | 
			
		||||
		+media-xs
 | 
			
		||||
			padding: 10px
 | 
			
		||||
 | 
			
		||||
		flex: 1
 | 
			
		||||
		padding: 25px 15px
 | 
			
		||||
 | 
			
		||||
	span
 | 
			
		||||
		color: $color-text-dark-secondary
 | 
			
		||||
		pointer-events: none
 | 
			
		||||
 | 
			
		||||
.blog-action
 | 
			
		||||
	display: flex
 | 
			
		||||
	padding: 10px
 | 
			
		||||
	position: absolute
 | 
			
		||||
	right: 0
 | 
			
		||||
	top: 0
 | 
			
		||||
	z-index: 1
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
// Specific tweaks for blogs in the context of a project
 | 
			
		||||
#project_context
 | 
			
		||||
	.blog_index-item
 | 
			
		||||
		+media-xs
 | 
			
		||||
			margin-left: 0
 | 
			
		||||
		padding: 0
 | 
			
		||||
		margin-left: 10px
 | 
			
		||||
 | 
			
		||||
		&.list
 | 
			
		||||
			margin-left: 35px !important
 | 
			
		||||
 | 
			
		||||
		.item-title,
 | 
			
		||||
		.item-info
 | 
			
		||||
			+media-xs
 | 
			
		||||
				padding-left: 0
 | 
			
		||||
			padding-left: 25px
 | 
			
		||||
 | 
			
		||||
	#blog_container
 | 
			
		||||
		.comments-container
 | 
			
		||||
			+media-sm
 | 
			
		||||
				margin-left: 10px
 | 
			
		||||
			margin-left: 30px
 | 
			
		||||
 | 
			
		||||
	.blog-archive-navigation
 | 
			
		||||
		margin-left: 35px
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										72
									
								
								src/styles/components/_alerts.sass
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								src/styles/components/_alerts.sass
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,72 @@
 | 
			
		||||
.alert
 | 
			
		||||
	margin-bottom: 0
 | 
			
		||||
	text-align: center
 | 
			
		||||
	padding: 10px 20px
 | 
			
		||||
	z-index: 16
 | 
			
		||||
 | 
			
		||||
	// overriden by alert types
 | 
			
		||||
	color: $color-text-dark
 | 
			
		||||
	background-color: $color-background
 | 
			
		||||
 | 
			
		||||
	&.alert-danger,
 | 
			
		||||
	&.alert-error
 | 
			
		||||
		background-color: lighten($color-danger, 35%)
 | 
			
		||||
		color: $color-danger
 | 
			
		||||
		.alert-icon, .close
 | 
			
		||||
			color: $color-danger
 | 
			
		||||
 | 
			
		||||
	&.alert-warning
 | 
			
		||||
		background-color: lighten($color-warning, 20%)
 | 
			
		||||
		color: darken($color-warning, 20%)
 | 
			
		||||
		.alert-icon, .close
 | 
			
		||||
			color: darken($color-warning, 20%)
 | 
			
		||||
 | 
			
		||||
	&.alert-success
 | 
			
		||||
		background-color: lighten($color-success, 45%)
 | 
			
		||||
		color: $color-success
 | 
			
		||||
 | 
			
		||||
		.alert-icon, .close
 | 
			
		||||
			color: $color-success
 | 
			
		||||
 | 
			
		||||
	&.alert-info
 | 
			
		||||
		background-color: lighten($color-info, 30%)
 | 
			
		||||
		color: darken($color-info, 10%)
 | 
			
		||||
		.alert-icon, .close
 | 
			
		||||
			color: darken($color-info, 10%)
 | 
			
		||||
 | 
			
		||||
	button.close
 | 
			
		||||
		position: absolute
 | 
			
		||||
		right: 10px
 | 
			
		||||
 | 
			
		||||
		i
 | 
			
		||||
			font-size: .8em
 | 
			
		||||
 | 
			
		||||
	i.alert-icon
 | 
			
		||||
		&:before
 | 
			
		||||
			font-family: "pillar-font"
 | 
			
		||||
			padding-right: 10px
 | 
			
		||||
 | 
			
		||||
		&.success:before
 | 
			
		||||
			content: '\e801'
 | 
			
		||||
		&.info:before
 | 
			
		||||
			content: "\e80c"
 | 
			
		||||
		&.warning:before
 | 
			
		||||
			content: "\e80b"
 | 
			
		||||
		&.danger:before,
 | 
			
		||||
		&.error:before
 | 
			
		||||
			content: "\e83d"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/* When there's an alert, disable the fixed top */
 | 
			
		||||
.alert+.navbar-fixed-top
 | 
			
		||||
	position: relative
 | 
			
		||||
	margin-bottom: 0
 | 
			
		||||
 | 
			
		||||
	&+.container
 | 
			
		||||
		padding-top: 0
 | 
			
		||||
 | 
			
		||||
.alert+.navbar
 | 
			
		||||
	position: relative
 | 
			
		||||
 | 
			
		||||
.alert+.navbar+.page-content
 | 
			
		||||
	padding-top: 0
 | 
			
		||||
							
								
								
									
										29
									
								
								src/styles/components/_base.sass
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								src/styles/components/_base.sass
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,29 @@
 | 
			
		||||
body
 | 
			
		||||
	height: 100%
 | 
			
		||||
 | 
			
		||||
	+media-sm
 | 
			
		||||
		width: 100%
 | 
			
		||||
		max-width: 100%
 | 
			
		||||
		min-width: auto
 | 
			
		||||
 | 
			
		||||
	+media-xs
 | 
			
		||||
		width: 100%
 | 
			
		||||
		max-width: 100%
 | 
			
		||||
		min-width: auto
 | 
			
		||||
 | 
			
		||||
.container
 | 
			
		||||
	+media-xs
 | 
			
		||||
		max-width: 100%
 | 
			
		||||
		min-width: auto
 | 
			
		||||
		padding:
 | 
			
		||||
			left: 0
 | 
			
		||||
			right: 0
 | 
			
		||||
 | 
			
		||||
	&.box
 | 
			
		||||
		+container-box
 | 
			
		||||
 | 
			
		||||
.page-content
 | 
			
		||||
	background-color: $white
 | 
			
		||||
 | 
			
		||||
.container-box
 | 
			
		||||
	+container-box
 | 
			
		||||
							
								
								
									
										14
									
								
								src/styles/components/_buttons.sass
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/styles/components/_buttons.sass
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,14 @@
 | 
			
		||||
.btn-outline
 | 
			
		||||
	background-color: transparent
 | 
			
		||||
	border-width: 1px
 | 
			
		||||
	transition: background-color .1s
 | 
			
		||||
 | 
			
		||||
	&:focus, &:active
 | 
			
		||||
		box-shadow: none
 | 
			
		||||
 | 
			
		||||
.btn-empty
 | 
			
		||||
	background-color: transparent
 | 
			
		||||
	border-color: transparent
 | 
			
		||||
 | 
			
		||||
	&:focus, &:active
 | 
			
		||||
		box-shadow: none
 | 
			
		||||
							
								
								
									
										132
									
								
								src/styles/components/_card.sass
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								src/styles/components/_card.sass
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,132 @@
 | 
			
		||||
.card-deck
 | 
			
		||||
	// Custom, as of bootstrap 4.1.3 there is no way to do this.
 | 
			
		||||
	&.card-deck-responsive
 | 
			
		||||
		@extend .row
 | 
			
		||||
 | 
			
		||||
		.card
 | 
			
		||||
			@extend .col-md-4
 | 
			
		||||
 | 
			
		||||
			+media-sm
 | 
			
		||||
				flex: 1 0 50%
 | 
			
		||||
				max-width: 50%
 | 
			
		||||
 | 
			
		||||
			+media-md
 | 
			
		||||
				flex: 1 0 33%
 | 
			
		||||
				max-width: 33%
 | 
			
		||||
 | 
			
		||||
			+media-lg
 | 
			
		||||
				flex: 1 0 33%
 | 
			
		||||
				max-width: 33%
 | 
			
		||||
 | 
			
		||||
			+media-xl
 | 
			
		||||
				flex: 1 0 25%
 | 
			
		||||
				max-width: 25%
 | 
			
		||||
 | 
			
		||||
			+media-xxl
 | 
			
		||||
				flex: 1 0 20%
 | 
			
		||||
				max-width: 20%
 | 
			
		||||
 | 
			
		||||
		&.card-3-columns .card
 | 
			
		||||
			+media-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
 | 
			
		||||
		@extend .border-0
 | 
			
		||||
 | 
			
		||||
	.card-body
 | 
			
		||||
		@extend .px-0
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
.card-image-fade
 | 
			
		||||
	&:hover
 | 
			
		||||
		.card-img-top
 | 
			
		||||
			opacity: .9
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
.card.asset
 | 
			
		||||
	color: $color-text
 | 
			
		||||
 | 
			
		||||
	&:hover
 | 
			
		||||
		text-decoration: none
 | 
			
		||||
 | 
			
		||||
	&.free
 | 
			
		||||
		overflow: hidden
 | 
			
		||||
 | 
			
		||||
		&:after
 | 
			
		||||
			+ribbon
 | 
			
		||||
			content: 'FREE'
 | 
			
		||||
			padding: 2px 50px
 | 
			
		||||
 | 
			
		||||
	.card-body
 | 
			
		||||
		position: relative // for placing the progress
 | 
			
		||||
 | 
			
		||||
		.card-text
 | 
			
		||||
			font-size: $font-size-xs
 | 
			
		||||
 | 
			
		||||
	.card-img-top
 | 
			
		||||
		background-color: $color-background
 | 
			
		||||
		background-size: cover
 | 
			
		||||
		background-position: center
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
	$card-progress-height: 5px
 | 
			
		||||
	.progress
 | 
			
		||||
		height: $card-progress-height
 | 
			
		||||
		position: absolute
 | 
			
		||||
		top: -$card-progress-height
 | 
			
		||||
		width: 100%
 | 
			
		||||
 | 
			
		||||
.card-img-top
 | 
			
		||||
	&.card-icon
 | 
			
		||||
		display: flex
 | 
			
		||||
		align-items: center
 | 
			
		||||
		justify-content: center
 | 
			
		||||
		font-size: 2em
 | 
			
		||||
 | 
			
		||||
		i
 | 
			
		||||
			opacity: .2
 | 
			
		||||
 | 
			
		||||
/* Tiny label for cards. e.g. 'WATCHED' on videos. */
 | 
			
		||||
.card-label
 | 
			
		||||
	background-color: rgba($black, .5)
 | 
			
		||||
	border-radius: 3px
 | 
			
		||||
	color: $white
 | 
			
		||||
	display: block
 | 
			
		||||
	font-size: $font-size-xxs
 | 
			
		||||
	left: 5px
 | 
			
		||||
	top: -27px // enough to be above the progress-bar
 | 
			
		||||
	position: absolute
 | 
			
		||||
	padding: 1px 5px
 | 
			
		||||
	z-index: 1
 | 
			
		||||
 | 
			
		||||
.card
 | 
			
		||||
	&.active
 | 
			
		||||
		.card-title
 | 
			
		||||
			color: $primary
 | 
			
		||||
							
								
								
									
										8
									
								
								src/styles/components/_checkbox.sass
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src/styles/components/_checkbox.sass
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
			
		||||
.checkbox label label
 | 
			
		||||
	padding-left: 0
 | 
			
		||||
 | 
			
		||||
.checkbox label input[type=checkbox] + label
 | 
			
		||||
	transition: color 100ms ease-in-out
 | 
			
		||||
 | 
			
		||||
.checkbox label input[type=checkbox]:checked + label
 | 
			
		||||
	color: $color-success !important
 | 
			
		||||
							
								
								
									
										44
									
								
								src/styles/components/_dropdown.sass
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								src/styles/components/_dropdown.sass
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,44 @@
 | 
			
		||||
// Global, we want all menus to look like this.
 | 
			
		||||
ul.dropdown-menu
 | 
			
		||||
	box-shadow: $dropdown-box-shadow
 | 
			
		||||
	top: 95% // So there is less gap between the dropdown and the item.
 | 
			
		||||
 | 
			
		||||
	> li
 | 
			
		||||
		&:first-child > a
 | 
			
		||||
			padding-top: ($dropdown-item-padding-y * 1.5)
 | 
			
		||||
		&:last-child > a
 | 
			
		||||
			padding-bottom: ($dropdown-item-padding-y * 1.5)
 | 
			
		||||
 | 
			
		||||
		> a
 | 
			
		||||
			padding-top: $dropdown-item-padding-y
 | 
			
		||||
			padding-bottom: $dropdown-item-padding-y
 | 
			
		||||
 | 
			
		||||
	.dropdown-divider
 | 
			
		||||
		margin: 0
 | 
			
		||||
 | 
			
		||||
	.dropdown-item:last-child
 | 
			
		||||
		border-bottom-left-radius: $border-radius
 | 
			
		||||
		border-bottom-right-radius: $border-radius
 | 
			
		||||
 | 
			
		||||
// Open dropdown on mouse hover dropdowns in the navbar.
 | 
			
		||||
nav .dropdown:hover
 | 
			
		||||
	ul.dropdown-menu
 | 
			
		||||
		display: block
 | 
			
		||||
 | 
			
		||||
nav .dropdown.large:hover
 | 
			
		||||
	.dropdown-menu
 | 
			
		||||
		@extend .d-flex
 | 
			
		||||
 | 
			
		||||
.dropdown.large.show
 | 
			
		||||
	@extend .d-flex
 | 
			
		||||
 | 
			
		||||
	.dropdown-menu.show
 | 
			
		||||
		@extend .d-flex
 | 
			
		||||
 | 
			
		||||
.dropdown-menu-tab
 | 
			
		||||
	display: none
 | 
			
		||||
	min-width: 100px
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
	&.show // .dropdown-menu-tab.show
 | 
			
		||||
		@extend .d-flex
 | 
			
		||||
							
								
								
									
										25
									
								
								src/styles/components/_flyout.sass
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/styles/components/_flyout.sass
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,25 @@
 | 
			
		||||
/* Flyouts (only used on notifications for now) */
 | 
			
		||||
.flyout
 | 
			
		||||
	background-color: $color-background
 | 
			
		||||
	border-radius: 3px
 | 
			
		||||
	border: thin solid darken($color-background, 3%)
 | 
			
		||||
	box-shadow: 1px 2px 2px rgba(black, .2)
 | 
			
		||||
	display: block
 | 
			
		||||
	font-size: .9em
 | 
			
		||||
 | 
			
		||||
	& .flyout-title
 | 
			
		||||
		cursor: default
 | 
			
		||||
		display: block
 | 
			
		||||
		float: left
 | 
			
		||||
		font-size: 1.1em
 | 
			
		||||
		font-weight: 600
 | 
			
		||||
		padding: 8px 10px 5px 10px
 | 
			
		||||
 | 
			
		||||
	&.notifications
 | 
			
		||||
		max-height: 1000%
 | 
			
		||||
		overflow-x: hidden
 | 
			
		||||
		position: absolute
 | 
			
		||||
		right: 0
 | 
			
		||||
		top: 40px
 | 
			
		||||
		width: 420px
 | 
			
		||||
		z-index: 9999
 | 
			
		||||
							
								
								
									
										117
									
								
								src/styles/components/_footer.sass
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								src/styles/components/_footer.sass
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,117 @@
 | 
			
		||||
/* FOOTER */
 | 
			
		||||
.footer-wrapper
 | 
			
		||||
	background-color: $color-background
 | 
			
		||||
	position: relative
 | 
			
		||||
 | 
			
		||||
	&:after
 | 
			
		||||
		background-color: $color-background
 | 
			
		||||
		bottom: 0
 | 
			
		||||
		content: ''
 | 
			
		||||
		position: fixed
 | 
			
		||||
		left: 0
 | 
			
		||||
		right: 0
 | 
			
		||||
		top: 0
 | 
			
		||||
		pointer-events: none
 | 
			
		||||
		z-index: -1
 | 
			
		||||
 | 
			
		||||
/* Footer Navigation */
 | 
			
		||||
footer
 | 
			
		||||
	font-size: .75em
 | 
			
		||||
	padding: 0 0 10px 0
 | 
			
		||||
 | 
			
		||||
	a
 | 
			
		||||
		color: $color-text-dark-primary
 | 
			
		||||
 | 
			
		||||
		&:hover
 | 
			
		||||
			color: $color-primary
 | 
			
		||||
 | 
			
		||||
	ul.links
 | 
			
		||||
		float: left
 | 
			
		||||
		padding: 0
 | 
			
		||||
		margin: 0
 | 
			
		||||
		list-style-type: none
 | 
			
		||||
 | 
			
		||||
		li
 | 
			
		||||
			padding: 0 15px 0 0
 | 
			
		||||
			margin: 0
 | 
			
		||||
			float: left
 | 
			
		||||
 | 
			
		||||
#hop
 | 
			
		||||
	display: flex
 | 
			
		||||
	align-items: center
 | 
			
		||||
	justify-content: center
 | 
			
		||||
	visibility: hidden
 | 
			
		||||
	position: fixed
 | 
			
		||||
	right: 25px
 | 
			
		||||
	bottom: 25px
 | 
			
		||||
	z-index: 999
 | 
			
		||||
	cursor: pointer
 | 
			
		||||
	opacity: 0
 | 
			
		||||
	background: $color-background-light
 | 
			
		||||
	width: 32px
 | 
			
		||||
	height: 32px
 | 
			
		||||
	border-radius: 50%
 | 
			
		||||
	color: $color-text-dark-secondary
 | 
			
		||||
	font-size: 2em
 | 
			
		||||
	box-shadow: 0 0 15px rgba(black, .2)
 | 
			
		||||
	transform: scale(0.5)
 | 
			
		||||
	transition: all 150ms ease-in-out
 | 
			
		||||
 | 
			
		||||
	&:hover
 | 
			
		||||
		transform: scale(1.2)
 | 
			
		||||
		background-color: $color-background-nav
 | 
			
		||||
 | 
			
		||||
	&.active
 | 
			
		||||
		visibility: visible
 | 
			
		||||
		opacity: 1
 | 
			
		||||
		transform: scale(1)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
.footer-navigation
 | 
			
		||||
	font-size: .85em
 | 
			
		||||
	margin-bottom: 5px
 | 
			
		||||
	color: lighten($color-text, 30%)
 | 
			
		||||
	border-top: thick solid lighten($color-text, 60%)
 | 
			
		||||
	padding:
 | 
			
		||||
		top: 15px
 | 
			
		||||
		bottom: 15px
 | 
			
		||||
 | 
			
		||||
	a
 | 
			
		||||
		color: lighten($color-text, 35%)
 | 
			
		||||
 | 
			
		||||
		&:hover
 | 
			
		||||
			color: $color-primary
 | 
			
		||||
 | 
			
		||||
	.footer-links
 | 
			
		||||
		i
 | 
			
		||||
			font-size: 80%
 | 
			
		||||
			position: absolute
 | 
			
		||||
			left: -14px
 | 
			
		||||
			top: 20%
 | 
			
		||||
 | 
			
		||||
	.special
 | 
			
		||||
		padding:
 | 
			
		||||
			top: 10px
 | 
			
		||||
			bottom: 15px
 | 
			
		||||
		font-size: .9em
 | 
			
		||||
		border-left: thin solid darken($color-background, 20%)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		img
 | 
			
		||||
			max-width: 100%
 | 
			
		||||
			opacity: .6
 | 
			
		||||
 | 
			
		||||
	ul.footer-social
 | 
			
		||||
		width: 100%
 | 
			
		||||
		text-align:center
 | 
			
		||||
		margin: 0 auto
 | 
			
		||||
		display: flex
 | 
			
		||||
		align-items: center
 | 
			
		||||
		justify-content: space-around
 | 
			
		||||
 | 
			
		||||
		li
 | 
			
		||||
			display: inline-block
 | 
			
		||||
			padding: 30px 0
 | 
			
		||||
 | 
			
		||||
			i
 | 
			
		||||
				font-size: 3em
 | 
			
		||||
							
								
								
									
										132
									
								
								src/styles/components/_forms.sass
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								src/styles/components/_forms.sass
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,132 @@
 | 
			
		||||
/* File Upload forms */
 | 
			
		||||
.fieldlist
 | 
			
		||||
	list-style: none
 | 
			
		||||
	padding: 0
 | 
			
		||||
	margin: 10px 0 0 0
 | 
			
		||||
 | 
			
		||||
	li.fieldlist-item
 | 
			
		||||
		background-color: $color-background-light
 | 
			
		||||
		border: thin solid $color-background
 | 
			
		||||
		border-left: 3px solid $color-primary
 | 
			
		||||
		border-top-right-radius: 3px
 | 
			
		||||
		border-bottom-right-radius: 3px
 | 
			
		||||
 | 
			
		||||
		margin-bottom: 10px
 | 
			
		||||
		padding: 10px
 | 
			
		||||
		+clearfix
 | 
			
		||||
 | 
			
		||||
		.form-group
 | 
			
		||||
			margin-bottom: 0 !important // override bs
 | 
			
		||||
			width: 100%
 | 
			
		||||
 | 
			
		||||
			input.form-control
 | 
			
		||||
				background-color: white !important
 | 
			
		||||
				padding: 0 10px !important
 | 
			
		||||
				border: thin solid $color-background-dark !important
 | 
			
		||||
 | 
			
		||||
		div[class$="slug"]
 | 
			
		||||
			width: 50%
 | 
			
		||||
			float: left
 | 
			
		||||
			display: flex
 | 
			
		||||
			align-items: center
 | 
			
		||||
 | 
			
		||||
			label
 | 
			
		||||
				margin-right: 10px
 | 
			
		||||
 | 
			
		||||
		.fieldlist-action-button
 | 
			
		||||
			+button($color-success, 3px)
 | 
			
		||||
			margin: 0 0 0 10px
 | 
			
		||||
			padding: 5px 10px
 | 
			
		||||
			text-transform: initial
 | 
			
		||||
 | 
			
		||||
.form-upload-file
 | 
			
		||||
	margin-bottom: 10px
 | 
			
		||||
	display: flex
 | 
			
		||||
	flex-direction: column
 | 
			
		||||
 | 
			
		||||
	.form-upload-progress
 | 
			
		||||
		margin-top: 10px
 | 
			
		||||
 | 
			
		||||
		.form-upload-progress-bar
 | 
			
		||||
			margin-top: 5px
 | 
			
		||||
			background-color: $color-success
 | 
			
		||||
			height: 5px
 | 
			
		||||
			min-width: 0
 | 
			
		||||
			border-radius: 3px
 | 
			
		||||
 | 
			
		||||
			&.progress-uploading
 | 
			
		||||
				background-color: hsl(hue($color-success), 80%, 65%) !important
 | 
			
		||||
 | 
			
		||||
			&.progress-processing
 | 
			
		||||
				+stripes($color-success, lighten($color-success, 15%), -45deg, 25px)
 | 
			
		||||
				+stripes-animate
 | 
			
		||||
				animation-duration: 1s
 | 
			
		||||
 | 
			
		||||
			&.progress-error
 | 
			
		||||
				background-color: $color-danger !important
 | 
			
		||||
 | 
			
		||||
	.preview-thumbnail
 | 
			
		||||
		width: 50px
 | 
			
		||||
		height: 50px
 | 
			
		||||
		min-width: 50px
 | 
			
		||||
		min-height: 50px
 | 
			
		||||
		margin-right: 10px
 | 
			
		||||
		margin-top: 5px
 | 
			
		||||
		border-radius: 3px
 | 
			
		||||
		background-color: $color-background
 | 
			
		||||
 | 
			
		||||
	.form-upload-file-meta-container
 | 
			
		||||
		display: flex
 | 
			
		||||
 | 
			
		||||
	.form-upload-file-meta
 | 
			
		||||
		list-style: none
 | 
			
		||||
		padding: 0
 | 
			
		||||
		margin: 0
 | 
			
		||||
		width: 100%
 | 
			
		||||
		display: flex
 | 
			
		||||
		flex-wrap: wrap
 | 
			
		||||
		flex: 1
 | 
			
		||||
 | 
			
		||||
		li
 | 
			
		||||
			display: inline-block
 | 
			
		||||
			padding: 5px 10px
 | 
			
		||||
 | 
			
		||||
			&:first-child
 | 
			
		||||
				padding-left: 0
 | 
			
		||||
 | 
			
		||||
			&.dimensions, &.size
 | 
			
		||||
				color: $color-text-dark-secondary
 | 
			
		||||
 | 
			
		||||
			&.delete
 | 
			
		||||
				margin-left: auto
 | 
			
		||||
 | 
			
		||||
			&.name
 | 
			
		||||
				+text-overflow-ellipsis
 | 
			
		||||
 | 
			
		||||
		.file_delete
 | 
			
		||||
			color: $color-danger
 | 
			
		||||
 | 
			
		||||
	.form-upload-file-actions
 | 
			
		||||
		list-style: none
 | 
			
		||||
		padding: 0
 | 
			
		||||
		margin: 0
 | 
			
		||||
		display: flex
 | 
			
		||||
		flex-wrap: wrap
 | 
			
		||||
 | 
			
		||||
		li
 | 
			
		||||
			display: inline-block
 | 
			
		||||
			padding: 5px 10px
 | 
			
		||||
 | 
			
		||||
		.file_delete
 | 
			
		||||
			color: $color-danger
 | 
			
		||||
 | 
			
		||||
.form-group
 | 
			
		||||
	&.error
 | 
			
		||||
		.form-control, input
 | 
			
		||||
			border-color: $color-danger !important
 | 
			
		||||
 | 
			
		||||
	ul.error
 | 
			
		||||
		padding: 5px 0 0 0
 | 
			
		||||
		margin: 0
 | 
			
		||||
		color: $color-danger
 | 
			
		||||
		list-style-type: none
 | 
			
		||||
							
								
								
									
										38
									
								
								src/styles/components/_inputs.sass
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/styles/components/_inputs.sass
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,38 @@
 | 
			
		||||
/* Inputs */
 | 
			
		||||
input, input.form-control,
 | 
			
		||||
textarea, textarea.form-control,
 | 
			
		||||
select, select.form-control
 | 
			
		||||
	+input-generic
 | 
			
		||||
 | 
			
		||||
label, label.control-label
 | 
			
		||||
	+label-generic
 | 
			
		||||
 | 
			
		||||
select, select.form-control
 | 
			
		||||
	border-top-left-radius: 3px
 | 
			
		||||
	border-top-right-radius: 3px
 | 
			
		||||
	background-color: $color-background-light
 | 
			
		||||
 | 
			
		||||
	option
 | 
			
		||||
		background-color: white
 | 
			
		||||
 | 
			
		||||
input.fileupload
 | 
			
		||||
	background-color: transparent
 | 
			
		||||
	display: block
 | 
			
		||||
	margin-top: 10px
 | 
			
		||||
 | 
			
		||||
textarea
 | 
			
		||||
	resize: vertical
 | 
			
		||||
 | 
			
		||||
button, .btn
 | 
			
		||||
	&.disabled
 | 
			
		||||
		opacity: .5 !important
 | 
			
		||||
		pointer-events: none !important
 | 
			
		||||
		text-shadow: none !important
 | 
			
		||||
		user-select: none !important
 | 
			
		||||
 | 
			
		||||
.input-group-flex
 | 
			
		||||
	display: flex
 | 
			
		||||
 | 
			
		||||
.input-group-separator
 | 
			
		||||
	margin: 10px 0
 | 
			
		||||
	border-top: thin solid $color-background
 | 
			
		||||
							
								
								
									
										44
									
								
								src/styles/components/_jumbotron.sass
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								src/styles/components/_jumbotron.sass
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,44 @@
 | 
			
		||||
// Mainly overrides bootstrap jumbotron settings
 | 
			
		||||
.jumbotron
 | 
			
		||||
	@extend .d-flex
 | 
			
		||||
	@extend .mb-0
 | 
			
		||||
	@extend .rounded-0
 | 
			
		||||
	background-size: cover
 | 
			
		||||
	margin-bottom: 0
 | 
			
		||||
	padding-top: 10em
 | 
			
		||||
	padding-bottom: 10em
 | 
			
		||||
	position: relative
 | 
			
		||||
 | 
			
		||||
	&:after
 | 
			
		||||
		background-color: rgba(black, .5)
 | 
			
		||||
		bottom: 0
 | 
			
		||||
		content: ''
 | 
			
		||||
		display: none
 | 
			
		||||
		left: 0
 | 
			
		||||
		position: absolute
 | 
			
		||||
		right: 0
 | 
			
		||||
		top: 0
 | 
			
		||||
		visibility: hidden
 | 
			
		||||
 | 
			
		||||
	// Black-transparent gradient from left to right to better read the overlay text.
 | 
			
		||||
	&.jumbotron-overlay
 | 
			
		||||
		*
 | 
			
		||||
			z-index: 1
 | 
			
		||||
		&:after
 | 
			
		||||
			display: block
 | 
			
		||||
			visibility: visible
 | 
			
		||||
 | 
			
		||||
	&.jumbotron-overlay-gradient
 | 
			
		||||
		*
 | 
			
		||||
			z-index: 1
 | 
			
		||||
		&:after
 | 
			
		||||
			background-color: transparent
 | 
			
		||||
			background-image: linear-gradient(45deg, rgba(black, .5) 25%, transparent 50%)
 | 
			
		||||
			display: block
 | 
			
		||||
			visibility: visible
 | 
			
		||||
 | 
			
		||||
		h2, p
 | 
			
		||||
			text-shadow: 1px 1px rgba(black, .2), 1px 1px 25px rgba(black, .5)
 | 
			
		||||
 | 
			
		||||
	&:hover
 | 
			
		||||
		text-decoration: none
 | 
			
		||||
							
								
								
									
										249
									
								
								src/styles/components/_navbar.sass
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										249
									
								
								src/styles/components/_navbar.sass
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,249 @@
 | 
			
		||||
/* Top level navigation bar. */
 | 
			
		||||
.navbar
 | 
			
		||||
	box-shadow: inset 0 -2px  $color-background
 | 
			
		||||
 | 
			
		||||
.nav
 | 
			
		||||
	border: none
 | 
			
		||||
	color: $color-text-dark-secondary
 | 
			
		||||
	padding: 0
 | 
			
		||||
	z-index: $z-index-base + 5
 | 
			
		||||
 | 
			
		||||
	nav
 | 
			
		||||
		margin-left: auto
 | 
			
		||||
		margin-right: 0
 | 
			
		||||
 | 
			
		||||
		.navbar-nav
 | 
			
		||||
			margin-right: 0
 | 
			
		||||
			+media-xs
 | 
			
		||||
				margin: 0
 | 
			
		||||
				width: 100%
 | 
			
		||||
 | 
			
		||||
	li
 | 
			
		||||
		user-select: none
 | 
			
		||||
		position: relative
 | 
			
		||||
 | 
			
		||||
		img.gravatar
 | 
			
		||||
			height: 28px
 | 
			
		||||
			position: relative
 | 
			
		||||
			width: 28px
 | 
			
		||||
 | 
			
		||||
		.special
 | 
			
		||||
			background-color: white
 | 
			
		||||
			border-radius: 999em
 | 
			
		||||
			box-shadow: 1px 1px 1px rgba(black, .2)
 | 
			
		||||
			display: inline-block
 | 
			
		||||
			font-size: 1.2em
 | 
			
		||||
			height: 18px
 | 
			
		||||
			left: 28px
 | 
			
		||||
			position: absolute
 | 
			
		||||
			top: 3px
 | 
			
		||||
			width: 18px
 | 
			
		||||
			z-index: 2
 | 
			
		||||
 | 
			
		||||
			&.subscriber
 | 
			
		||||
				background-color: $color-success
 | 
			
		||||
				color: white
 | 
			
		||||
				font-size: .6em
 | 
			
		||||
 | 
			
		||||
			&.demo
 | 
			
		||||
				background-color: $color-info
 | 
			
		||||
				color: white
 | 
			
		||||
				font-size: .6em
 | 
			
		||||
 | 
			
		||||
			&.none
 | 
			
		||||
				color: $color-danger
 | 
			
		||||
 | 
			
		||||
			i
 | 
			
		||||
				+position-center-translate
 | 
			
		||||
 | 
			
		||||
.dropdown
 | 
			
		||||
	.navbar-item
 | 
			
		||||
		&:hover
 | 
			
		||||
			box-shadow: none // Remove the blue underline usually on navbar, from dropdown items.
 | 
			
		||||
 | 
			
		||||
	ul.dropdown-menu
 | 
			
		||||
		li
 | 
			
		||||
			a
 | 
			
		||||
				white-space: nowrap
 | 
			
		||||
 | 
			
		||||
			.subitem // e.g. "Not Sintel? Log out"
 | 
			
		||||
				font-size: .8em
 | 
			
		||||
				text-transform: initial
 | 
			
		||||
 | 
			
		||||
			i
 | 
			
		||||
				width: 30px
 | 
			
		||||
 | 
			
		||||
			&.subscription-status
 | 
			
		||||
				a, a:hover
 | 
			
		||||
					color: $white
 | 
			
		||||
 | 
			
		||||
				&.none
 | 
			
		||||
					background-color: $color-danger
 | 
			
		||||
 | 
			
		||||
				&.subscriber
 | 
			
		||||
					background-color: $color-success
 | 
			
		||||
 | 
			
		||||
				&.demo
 | 
			
		||||
					background-color: $color-info
 | 
			
		||||
 | 
			
		||||
				span.info
 | 
			
		||||
					display: block
 | 
			
		||||
 | 
			
		||||
					span.renew
 | 
			
		||||
						display: block
 | 
			
		||||
						font-size: .9em
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
.nav-link
 | 
			
		||||
	@extend .d-flex
 | 
			
		||||
 | 
			
		||||
.nav-title
 | 
			
		||||
	white-space: nowrap
 | 
			
		||||
 | 
			
		||||
.navbar-item
 | 
			
		||||
	align-items: center
 | 
			
		||||
	display: flex
 | 
			
		||||
	user-select: none
 | 
			
		||||
	color: inherit
 | 
			
		||||
 | 
			
		||||
	+media-sm
 | 
			
		||||
		padding-left: 10px
 | 
			
		||||
		padding-right: 10px
 | 
			
		||||
 | 
			
		||||
	&:hover, &:focus
 | 
			
		||||
		color: $primary
 | 
			
		||||
		background-color: transparent
 | 
			
		||||
		box-shadow: inset 0 -3px 0 $primary
 | 
			
		||||
		text-decoration: none
 | 
			
		||||
 | 
			
		||||
	&:focus
 | 
			
		||||
		box-shadow: inset 0 -3px 0 $primary
 | 
			
		||||
 | 
			
		||||
	&.active
 | 
			
		||||
		color: $primary
 | 
			
		||||
		box-shadow: inset 0 -3px 0 $primary
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/* Secondary navigation. */
 | 
			
		||||
$nav-secondary-bar-size: -2px
 | 
			
		||||
.nav-secondary
 | 
			
		||||
	align-items: center
 | 
			
		||||
	box-shadow: inset 0 $nav-secondary-bar-size 0 0 $color-background
 | 
			
		||||
 | 
			
		||||
	.nav-link
 | 
			
		||||
		color: $color-text
 | 
			
		||||
		cursor: pointer
 | 
			
		||||
		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,
 | 
			
		||||
			&.active
 | 
			
		||||
				color: $primary
 | 
			
		||||
				@extend .bg-white
 | 
			
		||||
 | 
			
		||||
				&:after
 | 
			
		||||
					background-image: linear-gradient($primary-accent 70%, $primary)
 | 
			
		||||
					height: 100%
 | 
			
		||||
					left: initial
 | 
			
		||||
					top: 0
 | 
			
		||||
					width: 3px
 | 
			
		||||
 | 
			
		||||
// Big navigation dropdown.
 | 
			
		||||
.nav-main
 | 
			
		||||
	.nav-secondary
 | 
			
		||||
		.nav-link
 | 
			
		||||
			@extend .pr-5
 | 
			
		||||
			box-shadow: none
 | 
			
		||||
 | 
			
		||||
			&.nav-see-more
 | 
			
		||||
				color: $primary
 | 
			
		||||
 | 
			
		||||
				i, span
 | 
			
		||||
					+active-gradient
 | 
			
		||||
 | 
			
		||||
.navbar-overlay
 | 
			
		||||
	+media-lg
 | 
			
		||||
		display: block
 | 
			
		||||
	bottom: 0
 | 
			
		||||
	display: none
 | 
			
		||||
	left: 0
 | 
			
		||||
	height: 100%
 | 
			
		||||
	position: absolute
 | 
			
		||||
	right: 0
 | 
			
		||||
	top: 0
 | 
			
		||||
	transition: background-color 350ms ease-in-out
 | 
			
		||||
	width: 100%
 | 
			
		||||
	z-index: 0
 | 
			
		||||
 | 
			
		||||
	&.is-active
 | 
			
		||||
		background-color: $color-background-nav
 | 
			
		||||
		text-shadow: none
 | 
			
		||||
 | 
			
		||||
nav.navbar
 | 
			
		||||
	.navbar-collapse
 | 
			
		||||
		> ul > li > .navbar-item
 | 
			
		||||
			padding: $navbar-nav-link-padding-x
 | 
			
		||||
			height: $nav-link-height
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
.navbar-backdrop-container
 | 
			
		||||
	width: 100%
 | 
			
		||||
	height: 100%
 | 
			
		||||
	position: absolute
 | 
			
		||||
	top: 0
 | 
			
		||||
	left: 0
 | 
			
		||||
	right: 0
 | 
			
		||||
	bottom: 0
 | 
			
		||||
 | 
			
		||||
	img
 | 
			
		||||
		display: none
 | 
			
		||||
		position: fixed
 | 
			
		||||
		width: 100%
 | 
			
		||||
		align-self: flex-start
 | 
			
		||||
		+media-md
 | 
			
		||||
			display: block
 | 
			
		||||
		+media-lg
 | 
			
		||||
			display: block
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
.nav-tabs .dropdown-menu, .nav-pills .dropdown-menu
 | 
			
		||||
	margin-top: 0
 | 
			
		||||
 | 
			
		||||
.navbar+.page-content
 | 
			
		||||
	padding-top: $nav-link-height
 | 
			
		||||
							
								
								
									
										75
									
								
								src/styles/components/_overlay.sass
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								src/styles/components/_overlay.sass
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,75 @@
 | 
			
		||||
#page-overlay
 | 
			
		||||
	background-color: rgba(black, .8)
 | 
			
		||||
	position: fixed
 | 
			
		||||
	top: 0
 | 
			
		||||
	bottom: 0
 | 
			
		||||
	right: 0
 | 
			
		||||
	left: 0
 | 
			
		||||
	z-index: $z-index-base + 15
 | 
			
		||||
	visibility: hidden
 | 
			
		||||
	opacity: 0
 | 
			
		||||
	transition: opacity 150ms ease-in-out
 | 
			
		||||
 | 
			
		||||
	display: flex
 | 
			
		||||
	align-items: center
 | 
			
		||||
	justify-content: center
 | 
			
		||||
 | 
			
		||||
	img
 | 
			
		||||
		user-select: none
 | 
			
		||||
		display: block
 | 
			
		||||
		max-height: 96%
 | 
			
		||||
		max-width: 96%
 | 
			
		||||
		z-index: 0
 | 
			
		||||
 | 
			
		||||
		box-shadow: 0 0 15px rgba(black, .2), 0 0 100px rgba(black, .5)
 | 
			
		||||
	p.caption
 | 
			
		||||
		position: absolute
 | 
			
		||||
		bottom: 1%
 | 
			
		||||
 | 
			
		||||
	&.active
 | 
			
		||||
		visibility: visible
 | 
			
		||||
		opacity: 1
 | 
			
		||||
 | 
			
		||||
	.no-preview
 | 
			
		||||
		user-select: none
 | 
			
		||||
		z-index: 0
 | 
			
		||||
		color: $color-text-light-secondary
 | 
			
		||||
 | 
			
		||||
	.nav-prev, .nav-next
 | 
			
		||||
		display: block
 | 
			
		||||
		font:
 | 
			
		||||
			family: 'pillar-font'
 | 
			
		||||
			size: 2em
 | 
			
		||||
		height: 80%
 | 
			
		||||
		width: 50px
 | 
			
		||||
		cursor: pointer
 | 
			
		||||
		color: $color-text-light-secondary
 | 
			
		||||
		z-index: 1
 | 
			
		||||
		+position-center-translate
 | 
			
		||||
 | 
			
		||||
		&:hover
 | 
			
		||||
			color: $color-text-light
 | 
			
		||||
 | 
			
		||||
		&:before, &:after
 | 
			
		||||
			+position-center-translate
 | 
			
		||||
 | 
			
		||||
	.nav-prev
 | 
			
		||||
		left: 50px
 | 
			
		||||
		&:before
 | 
			
		||||
			content: '\e839'
 | 
			
		||||
 | 
			
		||||
	.nav-next
 | 
			
		||||
		left: initial
 | 
			
		||||
		right: 0
 | 
			
		||||
		&:before
 | 
			
		||||
			content: '\e83a'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
	&.video
 | 
			
		||||
		.video-embed
 | 
			
		||||
			+position-center-translate
 | 
			
		||||
			position: fixed
 | 
			
		||||
 | 
			
		||||
			iframe
 | 
			
		||||
				width: 853px
 | 
			
		||||
				height: 480px
 | 
			
		||||
							
								
								
									
										26
									
								
								src/styles/components/_popover.sass
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								src/styles/components/_popover.sass
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
			
		||||
.popover
 | 
			
		||||
	background-color: lighten($color-background-nav, 5%)
 | 
			
		||||
	border-radius: 3px
 | 
			
		||||
	box-shadow: 1px 1px 2px rgba(black, .2)
 | 
			
		||||
	border: thin solid lighten($color-background-nav, 10%)
 | 
			
		||||
 | 
			
		||||
	&.in
 | 
			
		||||
		opacity: 1
 | 
			
		||||
 | 
			
		||||
	.popover-title
 | 
			
		||||
		background-color: lighten($color-background-nav, 10%)
 | 
			
		||||
		border-bottom: thin solid lighten($color-background-nav, 3%)
 | 
			
		||||
		color: $color-text-light-primary
 | 
			
		||||
 | 
			
		||||
	.popover-content
 | 
			
		||||
		color: $color-text-light
 | 
			
		||||
		font-size: .9em
 | 
			
		||||
 | 
			
		||||
	&.top .arrow:after
 | 
			
		||||
		border-top-color: lighten($color-background-nav, 5%)
 | 
			
		||||
	&.bottom .arrow:after
 | 
			
		||||
		border-bottom-color: lighten($color-background-nav, 5%)
 | 
			
		||||
	&.left .arrow:after
 | 
			
		||||
		border-left-color: lighten($color-background-nav, 5%)
 | 
			
		||||
	&.right .arrow:after
 | 
			
		||||
		border-right-color: lighten($color-background-nav, 5%)
 | 
			
		||||
							
								
								
									
										87
									
								
								src/styles/components/_search.sass
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								src/styles/components/_search.sass
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,87 @@
 | 
			
		||||
#search-overlay
 | 
			
		||||
	position: absolute
 | 
			
		||||
	top: 0
 | 
			
		||||
	left: 0
 | 
			
		||||
	right: 0
 | 
			
		||||
	bottom: 0
 | 
			
		||||
	width: 100%
 | 
			
		||||
	height: 100%
 | 
			
		||||
	pointer-events: none
 | 
			
		||||
	visibility: hidden
 | 
			
		||||
	opacity: 0
 | 
			
		||||
	z-index: $z-index-base + 4
 | 
			
		||||
	transition: opacity 150ms ease-in-out
 | 
			
		||||
 | 
			
		||||
	&.active
 | 
			
		||||
		opacity: 1
 | 
			
		||||
		visibility: visible
 | 
			
		||||
		background-color: rgba($color-background-nav, .7)
 | 
			
		||||
 | 
			
		||||
.search-input
 | 
			
		||||
	+media-lg
 | 
			
		||||
		max-width: 350px
 | 
			
		||||
	+media-md
 | 
			
		||||
		max-width: 350px
 | 
			
		||||
	+media-sm
 | 
			
		||||
		max-width: 120px
 | 
			
		||||
	+media-xs
 | 
			
		||||
		display: block
 | 
			
		||||
		margin: 0 10px
 | 
			
		||||
		position: absolute
 | 
			
		||||
		z-index: $z-index-base
 | 
			
		||||
		right: 5px
 | 
			
		||||
	position: relative
 | 
			
		||||
	float: left
 | 
			
		||||
	padding: 0
 | 
			
		||||
	margin: 0
 | 
			
		||||
 | 
			
		||||
	.search-icon
 | 
			
		||||
		position: absolute
 | 
			
		||||
		top: 4px
 | 
			
		||||
		left: 10px
 | 
			
		||||
		cursor: pointer
 | 
			
		||||
 | 
			
		||||
		&:after
 | 
			
		||||
			@extend .tooltip-inner
 | 
			
		||||
 | 
			
		||||
			content: 'Use advanced search...'
 | 
			
		||||
			font-size: .85em
 | 
			
		||||
			font-style: normal
 | 
			
		||||
			left: -10px
 | 
			
		||||
			opacity: 0
 | 
			
		||||
			pointer-events: none
 | 
			
		||||
			position: absolute
 | 
			
		||||
			top: 30px
 | 
			
		||||
			transition: top 150ms ease-in-out, opacity 150ms ease-in-out
 | 
			
		||||
			width: 150px
 | 
			
		||||
 | 
			
		||||
		&:hover
 | 
			
		||||
			&:after
 | 
			
		||||
				opacity: 1
 | 
			
		||||
				top: 35px
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
	#cloud-search, .tt-hint
 | 
			
		||||
		+text-overflow-ellipsis
 | 
			
		||||
		border: thin solid $color-background
 | 
			
		||||
		border-radius: 3px
 | 
			
		||||
		font:
 | 
			
		||||
			size: 1em
 | 
			
		||||
			weight: 400
 | 
			
		||||
		margin: 0
 | 
			
		||||
		min-height: 32px
 | 
			
		||||
		outline: none
 | 
			
		||||
		padding: 0 20px 0 40px
 | 
			
		||||
		transition: border 100ms ease-in-out
 | 
			
		||||
 | 
			
		||||
		&:focus
 | 
			
		||||
			box-shadow: none
 | 
			
		||||
			border: none
 | 
			
		||||
 | 
			
		||||
		&::placeholder
 | 
			
		||||
			color: rgba($color-text, .5)
 | 
			
		||||
			transition: color 150ms ease-in-out
 | 
			
		||||
 | 
			
		||||
		&:hover
 | 
			
		||||
			&::placeholder
 | 
			
		||||
				color: rgba($color-text, .6)
 | 
			
		||||
							
								
								
									
										6
									
								
								src/styles/components/_shortcode.sass
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/styles/components/_shortcode.sass
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
			
		||||
p.shortcode.nocap
 | 
			
		||||
	padding: 0.6em 3em
 | 
			
		||||
	font-size: .8em
 | 
			
		||||
	color: $color-text-dark-primary
 | 
			
		||||
	background-color: $color-background-dark
 | 
			
		||||
	border-radius: 3px
 | 
			
		||||
							
								
								
									
										21
									
								
								src/styles/components/_statusbar.sass
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/styles/components/_statusbar.sass
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
			
		||||
#status-bar
 | 
			
		||||
	opacity: 0
 | 
			
		||||
	transition: all 250ms ease-in-out
 | 
			
		||||
 | 
			
		||||
	i
 | 
			
		||||
		margin-right: 5px
 | 
			
		||||
 | 
			
		||||
	&.info
 | 
			
		||||
		color: $color-info
 | 
			
		||||
	&.error
 | 
			
		||||
		color: $color-danger
 | 
			
		||||
	&.warning
 | 
			
		||||
		color: $color-warning
 | 
			
		||||
	&.success
 | 
			
		||||
		color: $color-success
 | 
			
		||||
	&.default
 | 
			
		||||
		color: $color-text-light
 | 
			
		||||
 | 
			
		||||
	&.active
 | 
			
		||||
		opacity: 1
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										5
									
								
								src/styles/components/_tooltip.sass
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/styles/components/_tooltip.sass
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,5 @@
 | 
			
		||||
.tooltip
 | 
			
		||||
	transition: none
 | 
			
		||||
 | 
			
		||||
	.tooltip-inner
 | 
			
		||||
		white-space: nowrap
 | 
			
		||||
@@ -1,7 +1,12 @@
 | 
			
		||||
/* SCROLL TO READ ABOUT UPDATING THIS FILE FROM FONTELLO */
 | 
			
		||||
 | 
			
		||||
/* Makes it possible to override the path before importing font-pillar.sass */
 | 
			
		||||
$pillar-font-path: "../font" !default
 | 
			
		||||
 | 
			
		||||
/* Font properties. */
 | 
			
		||||
@font-face
 | 
			
		||||
  font-family: 'pillar-font'
 | 
			
		||||
  src: url('../font/pillar-font.eot?55726379')
 | 
			
		||||
  src: url('../font/pillar-font.eot?55726379#iefix') format("embedded-opentype"), url('../font/pillar-font.woff2?55726379') format("woff2"), url('../font/pillar-font.woff?55726379') format("woff")
 | 
			
		||||
  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
 | 
			
		||||
 | 
			
		||||
@@ -15,23 +20,99 @@
 | 
			
		||||
  width: 1em
 | 
			
		||||
  margin-right: .2em
 | 
			
		||||
  text-align: center
 | 
			
		||||
 | 
			
		||||
  /* opacity: .8;
 | 
			
		||||
  /* For safety - reset parent styles, that can break glyph codes
 | 
			
		||||
  font-variant: normal
 | 
			
		||||
  text-transform: none
 | 
			
		||||
  /* fix buttons height, for twitter bootstrap
 | 
			
		||||
  line-height: 1em
 | 
			
		||||
  /* Animation center compensation - margins should be symmetric
 | 
			
		||||
  /* remove if not needed
 | 
			
		||||
  margin-left: .2em
 | 
			
		||||
  /* you can be more comfortable with increased icons size
 | 
			
		||||
  /* font-size: 120%;
 | 
			
		||||
  /* Font smoothing. That was taken from TWBS
 | 
			
		||||
  -webkit-font-smoothing: antialiased
 | 
			
		||||
  -moz-osx-font-smoothing: grayscale
 | 
			
		||||
  /* Uncomment for 3D effect
 | 
			
		||||
  /* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3);
 | 
			
		||||
 | 
			
		||||
/* Icon aliases. */
 | 
			
		||||
/* Empty icons, multiple names for the same/unasigned icon, etc. */
 | 
			
		||||
.pi, .pi-blank
 | 
			
		||||
  &:after
 | 
			
		||||
    content: ''
 | 
			
		||||
    font-family: "pillar-font"
 | 
			
		||||
    font-style: normal
 | 
			
		||||
    font-weight: normal
 | 
			
		||||
    speak: none
 | 
			
		||||
    display: inline-block
 | 
			
		||||
    text-decoration: inherit
 | 
			
		||||
    width: 1em
 | 
			
		||||
    text-align: center
 | 
			
		||||
    font-variant: normal
 | 
			
		||||
    text-transform: none
 | 
			
		||||
    line-height: 1em
 | 
			
		||||
    -webkit-font-smoothing: antialiased
 | 
			
		||||
    -moz-osx-font-smoothing: grayscale
 | 
			
		||||
    position: relative
 | 
			
		||||
 | 
			
		||||
  &:before
 | 
			
		||||
    position: relative
 | 
			
		||||
 | 
			
		||||
.pi-svnman:before
 | 
			
		||||
  content: '\f1c0'
 | 
			
		||||
 | 
			
		||||
/* Assets */
 | 
			
		||||
.pi-group
 | 
			
		||||
  @extend .pi-folder
 | 
			
		||||
.pi-video
 | 
			
		||||
  @extend .pi-film-thick
 | 
			
		||||
.pi-file
 | 
			
		||||
  @extend .pi-file-archive
 | 
			
		||||
.pi-asset
 | 
			
		||||
  @extend .pi-file-archive
 | 
			
		||||
.pi-group_texture
 | 
			
		||||
  @extend .pi-folder-texture
 | 
			
		||||
.pi-post
 | 
			
		||||
  @extend .pi-newspaper
 | 
			
		||||
.pi-page
 | 
			
		||||
  @extend .pi-document
 | 
			
		||||
 | 
			
		||||
/* License */
 | 
			
		||||
.pi-license-cc-zero:before
 | 
			
		||||
  content: '\e85a'
 | 
			
		||||
.pi-license-cc-sa:before
 | 
			
		||||
  content: '\e858'
 | 
			
		||||
  top: 1px
 | 
			
		||||
.pi-license-cc-nd:before
 | 
			
		||||
  content: '\e859'
 | 
			
		||||
.pi-license-cc-nc:before
 | 
			
		||||
  content: '\e857'
 | 
			
		||||
 | 
			
		||||
.pi-license-cc-0
 | 
			
		||||
  @extend .pi-license-cc-zero
 | 
			
		||||
  position: relative
 | 
			
		||||
  top: 1px
 | 
			
		||||
.pi-license-cc-by-sa
 | 
			
		||||
  @extend .pi-license-cc-sa
 | 
			
		||||
.pi-license-cc-by-nd
 | 
			
		||||
  @extend .pi-license-cc-nd
 | 
			
		||||
.pi-license-cc-by-nc
 | 
			
		||||
  @extend .pi-license-cc-nc
 | 
			
		||||
 | 
			
		||||
.pi-license-cc-by-sa,
 | 
			
		||||
.pi-license-cc-by-nd,
 | 
			
		||||
.pi-license-cc-by-nc
 | 
			
		||||
  @extend .pi
 | 
			
		||||
 | 
			
		||||
  &:after
 | 
			
		||||
    content: '\e807'
 | 
			
		||||
    left: -27px
 | 
			
		||||
 | 
			
		||||
  &:before
 | 
			
		||||
    left: 27px
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
 * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
 | 
			
		||||
 * Here begins the CSS code generated by fontello.com by using     *
 | 
			
		||||
 * the config.json file in /pillar/web/static/assets/font          *
 | 
			
		||||
 * Just convert the icon classes from pillar-font.css to Sass      *
 | 
			
		||||
 * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
 | 
			
		||||
 * When adding icons, only add/overwrite icon classes e.g. .pi-bla *
 | 
			
		||||
 * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
.pi-collection-plus:before
 | 
			
		||||
  content: '\e800'
 | 
			
		||||
@@ -428,6 +509,11 @@
 | 
			
		||||
 | 
			
		||||
/* ''
 | 
			
		||||
 | 
			
		||||
.pi-speed:before
 | 
			
		||||
  content: '\e84f'
 | 
			
		||||
 | 
			
		||||
/* ''
 | 
			
		||||
 | 
			
		||||
.pi-attention:before
 | 
			
		||||
  content: '\e850'
 | 
			
		||||
 | 
			
		||||
@@ -578,11 +664,6 @@
 | 
			
		||||
 | 
			
		||||
/* ''
 | 
			
		||||
 | 
			
		||||
.pi-users:before
 | 
			
		||||
  content: '\e86e'
 | 
			
		||||
 | 
			
		||||
/* ''
 | 
			
		||||
 | 
			
		||||
.pi-flamenco:before
 | 
			
		||||
  content: '\e86f'
 | 
			
		||||
 | 
			
		||||
@@ -603,6 +684,11 @@
 | 
			
		||||
 | 
			
		||||
/* ''
 | 
			
		||||
 | 
			
		||||
.pi-users:before
 | 
			
		||||
  content: '\e873'
 | 
			
		||||
 | 
			
		||||
/* ''
 | 
			
		||||
 | 
			
		||||
.pi-pause:before
 | 
			
		||||
  content: '\f00e'
 | 
			
		||||
 | 
			
		||||
@@ -638,6 +724,16 @@
 | 
			
		||||
 | 
			
		||||
/* ''
 | 
			
		||||
 | 
			
		||||
.pi-social-instagram:before
 | 
			
		||||
  content: '\f16d'
 | 
			
		||||
 | 
			
		||||
/* ''
 | 
			
		||||
 | 
			
		||||
.pi-database:before
 | 
			
		||||
  content: '\f1c0'
 | 
			
		||||
 | 
			
		||||
/* ''
 | 
			
		||||
 | 
			
		||||
.pi-newspaper:before
 | 
			
		||||
  content: '\f1ea'
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,15 +1,84 @@
 | 
			
		||||
@import _normalize
 | 
			
		||||
// Bootstrap variables and utilities.
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/functions"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/variables"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/mixins"
 | 
			
		||||
 | 
			
		||||
@import _config
 | 
			
		||||
@import _utils
 | 
			
		||||
 | 
			
		||||
// Bootstrap components.
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/root"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/reboot"
 | 
			
		||||
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/type"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/images"
 | 
			
		||||
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/code"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/grid"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/tables"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/forms"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/buttons"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/transitions"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/dropdown"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/button-group"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/input-group"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/custom-forms"
 | 
			
		||||
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/nav"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/navbar"
 | 
			
		||||
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/card"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/breadcrumb"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/pagination"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/badge"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/jumbotron"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/alert"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/progress"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/media"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/list-group"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/close"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/modal"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/tooltip"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/popover"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/carousel"
 | 
			
		||||
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/utilities"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/print"
 | 
			
		||||
 | 
			
		||||
// Pillar components.
 | 
			
		||||
@import "apps_base"
 | 
			
		||||
@import "components/base"
 | 
			
		||||
 | 
			
		||||
@import "components/jumbotron"
 | 
			
		||||
@import "components/alerts"
 | 
			
		||||
@import "components/navbar"
 | 
			
		||||
@import "components/dropdown"
 | 
			
		||||
@import "components/footer"
 | 
			
		||||
@import "components/shortcode"
 | 
			
		||||
@import "components/statusbar"
 | 
			
		||||
@import "components/search"
 | 
			
		||||
 | 
			
		||||
@import "components/flyout"
 | 
			
		||||
@import "components/forms"
 | 
			
		||||
@import "components/inputs"
 | 
			
		||||
@import "components/buttons"
 | 
			
		||||
@import "components/popover"
 | 
			
		||||
@import "components/tooltip"
 | 
			
		||||
@import "components/checkbox"
 | 
			
		||||
@import "components/overlay"
 | 
			
		||||
@import "components/card"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/* Generic styles (comments, notifications, etc) come from base.css */
 | 
			
		||||
@import _notifications
 | 
			
		||||
@import _comments
 | 
			
		||||
 | 
			
		||||
@import _project
 | 
			
		||||
@import _project-sharing
 | 
			
		||||
@import _project-dashboard
 | 
			
		||||
@import _user
 | 
			
		||||
@import _search
 | 
			
		||||
@import _organizations
 | 
			
		||||
@import _search
 | 
			
		||||
 | 
			
		||||
/* services, about, etc */
 | 
			
		||||
@import _pages
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,8 @@
 | 
			
		||||
/* jsTree overrides */
 | 
			
		||||
 | 
			
		||||
$tree-color-text: $color-text-dark-primary
 | 
			
		||||
$tree-color-highlight: hsl(hue($color-background-active), 40%, 50%)
 | 
			
		||||
$tree-color-highlight-background: hsl(hue($color-background-active), 40%, 50%)
 | 
			
		||||
$tree-color-highlight-background-text: white
 | 
			
		||||
$tree-color-highlight: $color-primary-accent
 | 
			
		||||
$tree-color-highlight-background: $white
 | 
			
		||||
$tree-color-highlight-background-text: $primary
 | 
			
		||||
 | 
			
		||||
.jstree-default
 | 
			
		||||
	/* list item */
 | 
			
		||||
@@ -34,11 +33,10 @@ $tree-color-highlight-background-text: white
 | 
			
		||||
 | 
			
		||||
		&[data-node-type="page"],
 | 
			
		||||
		&[data-node-type="blog"]
 | 
			
		||||
			color: darken($tree-color-highlight, 5%)
 | 
			
		||||
			font-weight: bold
 | 
			
		||||
 | 
			
		||||
			.jstree-anchor
 | 
			
		||||
				padding: 5px 8px 1px 8px
 | 
			
		||||
				padding: 0 6px
 | 
			
		||||
 | 
			
		||||
				&:after
 | 
			
		||||
					top: 3px !important
 | 
			
		||||
@@ -63,49 +61,48 @@ $tree-color-highlight-background-text: white
 | 
			
		||||
		&.jstree-open
 | 
			
		||||
			/* Text of children for an open tree (like a folder) */
 | 
			
		||||
			.jstree-children > .jstree-node
 | 
			
		||||
				padding-left: 15px !important
 | 
			
		||||
				padding-left: 16px !important
 | 
			
		||||
 | 
			
		||||
				.jstree-icon:empty
 | 
			
		||||
					left: 20px !important
 | 
			
		||||
 | 
			
		||||
					// Tweaks for specific icons
 | 
			
		||||
					&.pi-file-archive
 | 
			
		||||
						left: 22px !important
 | 
			
		||||
						left: 25px !important
 | 
			
		||||
					&.pi-folder
 | 
			
		||||
						left: 21px !important
 | 
			
		||||
						left: 20px !important
 | 
			
		||||
						font-size: .9em !important
 | 
			
		||||
					&.pi-film-thick
 | 
			
		||||
						left: 22px !important
 | 
			
		||||
					&.pi-splay
 | 
			
		||||
						left: 20px !important
 | 
			
		||||
						font-size: .85em !important
 | 
			
		||||
 | 
			
		||||
				.jstree-anchor
 | 
			
		||||
					box-shadow: inset 1px 0 0 0 rgba($tree-color-text, .2)
 | 
			
		||||
					// box-shadow: inset 1px 0 0 0 $color-background
 | 
			
		||||
 | 
			
		||||
		/* Closed Folder */
 | 
			
		||||
		// &.jstree-closed
 | 
			
		||||
 | 
			
		||||
		&.jstree-open .jstree-icon.jstree-ocl,
 | 
			
		||||
		&.jstree-closed .jstree-icon.jstree-ocl
 | 
			
		||||
			float: left
 | 
			
		||||
			min-width: 30px
 | 
			
		||||
			opacity: 0
 | 
			
		||||
			position: absolute
 | 
			
		||||
			z-index: 1
 | 
			
		||||
			opacity: 0
 | 
			
		||||
			min-width: 30px
 | 
			
		||||
			float: left
 | 
			
		||||
 | 
			
		||||
		/* The text of the last level item */
 | 
			
		||||
		.jstree-anchor
 | 
			
		||||
			+media-xs
 | 
			
		||||
				width: 98%
 | 
			
		||||
				padding: 0 !important
 | 
			
		||||
				width: 98%
 | 
			
		||||
			border: none
 | 
			
		||||
			font-size: 13px
 | 
			
		||||
			height: inherit
 | 
			
		||||
			line-height: 26px
 | 
			
		||||
			line-height: 24px
 | 
			
		||||
			overflow: hidden
 | 
			
		||||
			padding-left: 28px
 | 
			
		||||
			padding-right: 10px
 | 
			
		||||
			text-overflow: ellipsis
 | 
			
		||||
			transition: none
 | 
			
		||||
			transition: color 50ms ease-in-out, background-color 100ms ease-in-out
 | 
			
		||||
			white-space: nowrap
 | 
			
		||||
			width: 100%
 | 
			
		||||
 | 
			
		||||
@@ -113,7 +110,7 @@ $tree-color-highlight-background-text: white
 | 
			
		||||
			&:after
 | 
			
		||||
				content: '\e83a' !important
 | 
			
		||||
				font-family: 'pillar-font'
 | 
			
		||||
				color: white
 | 
			
		||||
				color: $tree-color-highlight-background-text
 | 
			
		||||
				display: none
 | 
			
		||||
				position: absolute
 | 
			
		||||
				right: 7px
 | 
			
		||||
@@ -121,31 +118,31 @@ $tree-color-highlight-background-text: white
 | 
			
		||||
 | 
			
		||||
			// Icon, not selected
 | 
			
		||||
			.jstree-icon
 | 
			
		||||
				color: $color-text-dark-secondary
 | 
			
		||||
				font-size: 95% !important
 | 
			
		||||
				color: $tree-color-text
 | 
			
		||||
				margin: 0 !important
 | 
			
		||||
 | 
			
		||||
			/* Selected item */
 | 
			
		||||
			&.jstree-clicked
 | 
			
		||||
				background-color: $tree-color-highlight-background !important
 | 
			
		||||
				color: white !important
 | 
			
		||||
				color: $tree-color-highlight-background-text !important
 | 
			
		||||
				font-weight: bold
 | 
			
		||||
 | 
			
		||||
				&:after
 | 
			
		||||
					display: block
 | 
			
		||||
					color: white !important
 | 
			
		||||
					color: $tree-color-highlight-background-text !important
 | 
			
		||||
 | 
			
		||||
				.jstree-ocl,
 | 
			
		||||
				.jstree-icon
 | 
			
		||||
					color: white
 | 
			
		||||
					color: $tree-color-highlight-background-text
 | 
			
		||||
 | 
			
		||||
				/* hover an active item */
 | 
			
		||||
				&.jstree-hovered
 | 
			
		||||
					background-color: lighten($tree-color-highlight-background, 10%) !important
 | 
			
		||||
					box-shadow: none
 | 
			
		||||
					color: white !important
 | 
			
		||||
					color: $tree-color-highlight-background-text !important
 | 
			
		||||
 | 
			
		||||
				&.jstree-hovered .jstree-icon
 | 
			
		||||
					color: white !important
 | 
			
		||||
					color: $tree-color-highlight-background-text !important
 | 
			
		||||
 | 
			
		||||
			.jstree-hovered
 | 
			
		||||
				background-color: rgba($tree-color-highlight, .1) !important
 | 
			
		||||
@@ -184,8 +181,8 @@ $tree-color-highlight-background-text: white
 | 
			
		||||
		position: absolute
 | 
			
		||||
 | 
			
		||||
		&:empty
 | 
			
		||||
			line-height: 26px
 | 
			
		||||
			left: 5px
 | 
			
		||||
			line-height: 24px
 | 
			
		||||
			left: 3px
 | 
			
		||||
 | 
			
		||||
	&.is_subscriber
 | 
			
		||||
		.jstree-node
 | 
			
		||||
@@ -269,7 +266,7 @@ $tree-color-highlight-background-text: white
 | 
			
		||||
 | 
			
		||||
.jstree-default .jstree-node.jstree-closed .jstree-icon.jstree-ocl + .jstree-anchor,
 | 
			
		||||
.jstree-default .jstree-node.jstree-open .jstree-icon.jstree-ocl + .jstree-anchor
 | 
			
		||||
	padding-left: 28px !important
 | 
			
		||||
	padding-left: 24px !important
 | 
			
		||||
 | 
			
		||||
/* hovered text */
 | 
			
		||||
.jstree-default .jstree-hovered,
 | 
			
		||||
@@ -280,11 +277,11 @@ $tree-color-highlight-background-text: white
 | 
			
		||||
 | 
			
		||||
a.jstree-anchor.jstree-clicked+ul li a.jstree-anchor.jstree-clicked
 | 
			
		||||
	background-color: rgba($tree-color-highlight-background, .8) !important
 | 
			
		||||
	color: white !important
 | 
			
		||||
	color: $tree-color-highlight-background-text !important
 | 
			
		||||
 | 
			
		||||
a.jstree-anchor.jstree-clicked+ul li a.jstree-anchor.jstree-clicked+ul li a.jstree-anchor.jstree-clicked
 | 
			
		||||
	background-color: rgba($tree-color-highlight-background, .8) !important
 | 
			
		||||
	color: white !important
 | 
			
		||||
	color: $tree-color-highlight-background-text !important
 | 
			
		||||
 | 
			
		||||
i.jstree-icon.jstree-ocl
 | 
			
		||||
	color: rgba($tree-color-text, .5) !important
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,7 @@
 | 
			
		||||
$videoplayer-controls-color: white
 | 
			
		||||
$videoplayer-background-color: $color-background-nav
 | 
			
		||||
$videoplayer-background-color: darken($primary, 10%)
 | 
			
		||||
 | 
			
		||||
$videoplayer-progress-bar-height: .5em
 | 
			
		||||
 | 
			
		||||
.video-js
 | 
			
		||||
	.vjs-big-play-button:before, .vjs-control:before, .vjs-modal-dialog
 | 
			
		||||
@@ -30,7 +32,6 @@ $videoplayer-background-color: $color-background-nav
 | 
			
		||||
	font-weight: normal
 | 
			
		||||
	font-style: normal
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
.vjs-icon-play
 | 
			
		||||
	font-family: VideoJS
 | 
			
		||||
	font-weight: normal
 | 
			
		||||
@@ -285,7 +286,6 @@ $videoplayer-background-color: $color-background-nav
 | 
			
		||||
	line-height: 1
 | 
			
		||||
	font-weight: normal
 | 
			
		||||
	font-style: normal
 | 
			
		||||
	font-family: Arial, Helvetica, sans-serif
 | 
			
		||||
	-webkit-user-select: none
 | 
			
		||||
	-moz-user-select: none
 | 
			
		||||
	-ms-user-select: none
 | 
			
		||||
@@ -453,20 +453,22 @@ body.vjs-full-window
 | 
			
		||||
	list-style: none
 | 
			
		||||
	margin: 0
 | 
			
		||||
	padding: 0.2em 0
 | 
			
		||||
	line-height: 1.4em
 | 
			
		||||
	font-size: 1.2em
 | 
			
		||||
	line-height: 1.8em
 | 
			
		||||
	font-size: 1.1em
 | 
			
		||||
	text-align: center
 | 
			
		||||
	text-transform: lowercase
 | 
			
		||||
 | 
			
		||||
	&:focus, &:hover
 | 
			
		||||
		outline: 0
 | 
			
		||||
		background-color: #73859f
 | 
			
		||||
		background-color: rgba(115, 133, 159, 0.5)
 | 
			
		||||
		background-color: darken($primary, 20%)
 | 
			
		||||
 | 
			
		||||
	&.vjs-selected
 | 
			
		||||
		background-color: $videoplayer-controls-color
 | 
			
		||||
		color: $videoplayer-background-color
 | 
			
		||||
 | 
			
		||||
		&:focus, &:hover
 | 
			
		||||
			background-color: $videoplayer-controls-color
 | 
			
		||||
			color: $videoplayer-background-color
 | 
			
		||||
 | 
			
		||||
	&.vjs-menu-title
 | 
			
		||||
		text-align: center
 | 
			
		||||
		text-transform: uppercase
 | 
			
		||||
@@ -486,12 +488,13 @@ body.vjs-full-window
 | 
			
		||||
	height: 0em
 | 
			
		||||
	margin-bottom: 1.5em
 | 
			
		||||
	border-top-color: $videoplayer-background-color
 | 
			
		||||
 | 
			
		||||
	.vjs-menu-content
 | 
			
		||||
		background-color: $videoplayer-background-color
 | 
			
		||||
		position: absolute
 | 
			
		||||
		width: 100%
 | 
			
		||||
		bottom: 1.5em
 | 
			
		||||
		max-height: 15em
 | 
			
		||||
		max-height: 25em
 | 
			
		||||
 | 
			
		||||
.vjs-workinghover .vjs-menu-button-popup:hover .vjs-menu, .vjs-menu-button-popup .vjs-menu.vjs-lock-showing
 | 
			
		||||
	display: block
 | 
			
		||||
@@ -655,12 +658,12 @@ body.vjs-full-window
 | 
			
		||||
		-moz-transition: all 0.2s
 | 
			
		||||
		-o-transition: all 0.2s
 | 
			
		||||
		transition: all 0.2s
 | 
			
		||||
		height: 0.3em
 | 
			
		||||
		height: $videoplayer-progress-bar-height
 | 
			
		||||
 | 
			
		||||
		.vjs-play-progress
 | 
			
		||||
			position: absolute
 | 
			
		||||
			display: block
 | 
			
		||||
			height: 0.3em
 | 
			
		||||
			height: $videoplayer-progress-bar-height
 | 
			
		||||
			margin: 0
 | 
			
		||||
			padding: 0
 | 
			
		||||
			width: 0
 | 
			
		||||
@@ -670,7 +673,7 @@ body.vjs-full-window
 | 
			
		||||
		.vjs-load-progress
 | 
			
		||||
			position: absolute
 | 
			
		||||
			display: block
 | 
			
		||||
			height: 0.3em
 | 
			
		||||
			height: $videoplayer-progress-bar-height
 | 
			
		||||
			margin: 0
 | 
			
		||||
			padding: 0
 | 
			
		||||
			width: 0
 | 
			
		||||
@@ -680,7 +683,7 @@ body.vjs-full-window
 | 
			
		||||
			div
 | 
			
		||||
				position: absolute
 | 
			
		||||
				display: block
 | 
			
		||||
				height: 0.3em
 | 
			
		||||
				height: $videoplayer-progress-bar-height
 | 
			
		||||
				margin: 0
 | 
			
		||||
				padding: 0
 | 
			
		||||
				width: 0
 | 
			
		||||
@@ -692,10 +695,11 @@ body.vjs-full-window
 | 
			
		||||
 | 
			
		||||
	.vjs-play-progress
 | 
			
		||||
		background-color: $videoplayer-controls-color
 | 
			
		||||
		border-radius: 999em
 | 
			
		||||
 | 
			
		||||
		&:before
 | 
			
		||||
			position: absolute
 | 
			
		||||
			top: -0.333333333333333em
 | 
			
		||||
			top: -($videoplayer-progress-bar-height / 2) // halfway the height of the progress bar
 | 
			
		||||
			right: -0.5em
 | 
			
		||||
 | 
			
		||||
		&:after
 | 
			
		||||
@@ -712,8 +716,8 @@ body.vjs-full-window
 | 
			
		||||
			z-index: 1
 | 
			
		||||
 | 
			
		||||
		.vjs-time-tooltip
 | 
			
		||||
			background-color: $videoplayer-background-color
 | 
			
		||||
			color: $videoplayer-controls-color
 | 
			
		||||
			background-color: $videoplayer-background-color
 | 
			
		||||
			z-index: 1
 | 
			
		||||
 | 
			
		||||
			&:after
 | 
			
		||||
@@ -735,9 +739,9 @@ body.vjs-full-window
 | 
			
		||||
 | 
			
		||||
.vjs-time-tooltip
 | 
			
		||||
	background-color: $videoplayer-controls-color
 | 
			
		||||
	border-radius: 3px
 | 
			
		||||
	border-radius: $border-radius
 | 
			
		||||
	color: $videoplayer-background-color
 | 
			
		||||
	font-family: $font-body
 | 
			
		||||
	font-family: $font-family-base
 | 
			
		||||
	font-size: 1.2em
 | 
			
		||||
	font-weight: bold
 | 
			
		||||
	padding: 5px 8px
 | 
			
		||||
@@ -851,9 +855,9 @@ body.vjs-full-window
 | 
			
		||||
		font-size: 0.9em
 | 
			
		||||
 | 
			
		||||
.vjs-slider-horizontal .vjs-volume-level
 | 
			
		||||
	height: 0.3em
 | 
			
		||||
	height: $videoplayer-progress-bar-height
 | 
			
		||||
	&:before
 | 
			
		||||
		top: -0.3em
 | 
			
		||||
		top: -$videoplayer-progress-bar-height
 | 
			
		||||
		right: -0.5em
 | 
			
		||||
 | 
			
		||||
.vjs-menu-button-popup
 | 
			
		||||
@@ -1022,14 +1026,15 @@ video::-webkit-media-text-track-display
 | 
			
		||||
 | 
			
		||||
.vjs-playback-rate
 | 
			
		||||
	.vjs-playback-rate-value
 | 
			
		||||
		font-size: 1.5em
 | 
			
		||||
		font-size: 1.25em
 | 
			
		||||
		line-height: 2
 | 
			
		||||
		position: absolute
 | 
			
		||||
		top: 0
 | 
			
		||||
		top: 3px
 | 
			
		||||
		left: 0
 | 
			
		||||
		width: 100%
 | 
			
		||||
		height: 100%
 | 
			
		||||
		text-align: center
 | 
			
		||||
 | 
			
		||||
	.vjs-menu
 | 
			
		||||
		width: 4em
 | 
			
		||||
		left: 0em
 | 
			
		||||
@@ -1041,7 +1046,6 @@ video::-webkit-media-text-track-display
 | 
			
		||||
	&:before
 | 
			
		||||
		color: $videoplayer-controls-color
 | 
			
		||||
		content: 'X'
 | 
			
		||||
		font-family: Arial, Helvetica, sans-serif
 | 
			
		||||
		font-size: 4em
 | 
			
		||||
		left: 0
 | 
			
		||||
		line-height: 1
 | 
			
		||||
 
 | 
			
		||||
@@ -1,14 +0,0 @@
 | 
			
		||||
@import _normalize
 | 
			
		||||
@import _config
 | 
			
		||||
@import _utils
 | 
			
		||||
 | 
			
		||||
@import _comments
 | 
			
		||||
@import _project
 | 
			
		||||
@import _project-sharing
 | 
			
		||||
@import _project-dashboard
 | 
			
		||||
@import _error
 | 
			
		||||
 | 
			
		||||
@import _search
 | 
			
		||||
 | 
			
		||||
@import plugins/_jstree
 | 
			
		||||
@import plugins/_js_select2
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user