Compare commits
	
		
			99 Commits
		
	
	
		
			wip-refact
			...
			wip-commen
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| d0e12401c0 | |||
| 411a6f75c5 | |||
| 07821c7f97 | |||
| 64b4ce3ba9 | |||
| 72417a9abb | |||
| 6ae9a5ddeb | |||
| a897e201ba | |||
| 3985a00c6f | |||
| 119291f817 | |||
| 801cda88bf | |||
| fc99713732 | |||
| 1d909faf49 | |||
| ed35c54361 | |||
| 411b15b1a0 | |||
| 9b85a938f3 | |||
| 989a40a7f7 | |||
| 64cc4dc9bf | |||
| 9182188647 | |||
| 5896f4cfdd | |||
| f9a407054d | |||
| 1c46e4c96b | |||
| 2990738b5d | |||
| e2432f6e9f | |||
| aa63389b4f | |||
| 5075cd5bd0 | |||
| ceef04455c | |||
| c8e62e3610 | |||
| ce7cf52d70 | |||
| dc2105fbb8 | |||
| 71185af880 | |||
| 041f8914b2 | |||
| b4ee5b59bd | |||
| 314ce40e71 | |||
| 7e941e2299 | |||
| 53811363ce | |||
| 51057e4d63 | |||
| a1a48c1941 | |||
| 19fdc75e60 | |||
| 879bcffc2b | |||
| 6ad12d0098 | |||
| a738cdcad8 | |||
| 199f37c5d7 | |||
| 4cf93f00f6 | |||
| eaf9235fa9 | |||
| 24ecf36896 | |||
| 86aa494aed | |||
| 5a5b97d362 | |||
| 831858a336 | |||
| e9d247fe97 | |||
| 1ddd8525c7 | |||
| c43941807c | |||
| bbad8eb5c5 | |||
| 04f00cdd4f | |||
| 66d9fd0908 | |||
| 516ef2ddc7 | |||
| 35fb07ee64 | |||
| f1d67894dc | |||
| aef2cf8c2d | |||
| d347ddac2c | |||
| 186ba167f1 | |||
| 847e97fe8c | |||
| 7ace5f4292 | |||
| 6cb85b06dc | |||
| 5c019e8d1c | |||
| 7796179021 | |||
| 26aca917c8 | |||
| e262a5c240 | |||
| e079ac4da1 | |||
| 83097cf473 | |||
| f4ade9cda7 | |||
| 31244a89e5 | |||
| 749c3dbd58 | |||
| b1d97e723f | |||
| 46bdd4f51c | |||
| 93720e226c | |||
| 9a0da126e6 | |||
| 45672565e9 | |||
| 3e1273d56c | |||
| fe86f76617 | |||
| 008d9b8880 | |||
| 13b606df45 | |||
| 57f5836829 | |||
| e40ba69872 | |||
| 0aeae2cabd | |||
| 601b94e23a | |||
| 00c4ec8741 | |||
| caee114d48 | |||
| 7fccf02e68 | |||
| 1c42e8fd07 | |||
| 77f855be3e | |||
| cede3e75db | |||
| 02a7014bf4 | |||
| 04e51a9d3f | |||
| d4fd6b5cda | |||
| 2935b442d8 | |||
| 567247f3fd | |||
| def52944bf | |||
| 8753a12dee | |||
| 5e07cfb9b2 | 
@@ -65,6 +65,12 @@ You can run the Celery Worker using `manage.py celery worker`.
 | 
			
		||||
 | 
			
		||||
Find other Celery operations with the `manage.py celery` command.
 | 
			
		||||
 | 
			
		||||
## Elasticsearch
 | 
			
		||||
 | 
			
		||||
Pillar uses [Elasticsearch](https://www.elastic.co/products/elasticsearch) to power the search engine.
 | 
			
		||||
You will need to run the `manage.py elastic reset_index` command to initialize the indexing.
 | 
			
		||||
If you need to reindex your documents in elastic you run the `manage.py elastic reindex` command.  
 | 
			
		||||
 | 
			
		||||
## Translations
 | 
			
		||||
 | 
			
		||||
If the language you want to support doesn't exist, you need to run: `translations init es_AR`.
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										138
									
								
								gulpfile.js
									
									
									
									
									
								
							
							
						
						
									
										138
									
								
								gulpfile.js
									
									
									
									
									
								
							@@ -1,20 +1,27 @@
 | 
			
		||||
var argv         = require('minimist')(process.argv.slice(2));
 | 
			
		||||
var autoprefixer = require('gulp-autoprefixer');
 | 
			
		||||
var cache        = require('gulp-cached');
 | 
			
		||||
var chmod        = require('gulp-chmod');
 | 
			
		||||
var concat       = require('gulp-concat');
 | 
			
		||||
var git          = require('gulp-git');
 | 
			
		||||
var gulpif       = require('gulp-if');
 | 
			
		||||
var gulp         = require('gulp');
 | 
			
		||||
var livereload   = require('gulp-livereload');
 | 
			
		||||
var plumber      = require('gulp-plumber');
 | 
			
		||||
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-es').default;
 | 
			
		||||
let argv         = require('minimist')(process.argv.slice(2));
 | 
			
		||||
let autoprefixer = require('gulp-autoprefixer');
 | 
			
		||||
let cache        = require('gulp-cached');
 | 
			
		||||
let chmod        = require('gulp-chmod');
 | 
			
		||||
let concat       = require('gulp-concat');
 | 
			
		||||
let git          = require('gulp-git');
 | 
			
		||||
let gulpif       = require('gulp-if');
 | 
			
		||||
let gulp         = require('gulp');
 | 
			
		||||
let livereload   = require('gulp-livereload');
 | 
			
		||||
let plumber      = require('gulp-plumber');
 | 
			
		||||
let pug          = require('gulp-pug');
 | 
			
		||||
let rename       = require('gulp-rename');
 | 
			
		||||
let sass         = require('gulp-sass');
 | 
			
		||||
let sourcemaps   = require('gulp-sourcemaps');
 | 
			
		||||
let uglify       = require('gulp-uglify-es').default;
 | 
			
		||||
let browserify   = require('browserify');
 | 
			
		||||
let babelify     = require('babelify');
 | 
			
		||||
let sourceStream = require('vinyl-source-stream');
 | 
			
		||||
let glob         = require('glob');
 | 
			
		||||
let es           = require('event-stream');
 | 
			
		||||
let path         = require('path');
 | 
			
		||||
let buffer = require('vinyl-buffer');
 | 
			
		||||
 | 
			
		||||
var enabled = {
 | 
			
		||||
let enabled = {
 | 
			
		||||
    uglify: argv.production,
 | 
			
		||||
    maps: !argv.production,
 | 
			
		||||
    failCheck: !argv.production,
 | 
			
		||||
@@ -24,20 +31,20 @@ var enabled = {
 | 
			
		||||
    chmod: argv.production,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
var destination = {
 | 
			
		||||
let destination = {
 | 
			
		||||
    css: 'pillar/web/static/assets/css',
 | 
			
		||||
    pug: 'pillar/web/templates',
 | 
			
		||||
    js: 'pillar/web/static/assets/js',
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var source = {
 | 
			
		||||
let source = {
 | 
			
		||||
    bootstrap: 'node_modules/bootstrap/',
 | 
			
		||||
    jquery: 'node_modules/jquery/',
 | 
			
		||||
    popper: 'node_modules/popper.js/'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* CSS */
 | 
			
		||||
gulp.task('styles', function() {
 | 
			
		||||
/* Stylesheets */
 | 
			
		||||
gulp.task('styles', function(done) {
 | 
			
		||||
    gulp.src('src/styles/**/*.sass')
 | 
			
		||||
        .pipe(gulpif(enabled.failCheck, plumber()))
 | 
			
		||||
        .pipe(gulpif(enabled.maps, sourcemaps.init()))
 | 
			
		||||
@@ -48,11 +55,12 @@ gulp.task('styles', function() {
 | 
			
		||||
        .pipe(gulpif(enabled.maps, sourcemaps.write(".")))
 | 
			
		||||
        .pipe(gulp.dest(destination.css))
 | 
			
		||||
        .pipe(gulpif(argv.livereload, livereload()));
 | 
			
		||||
    done();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/* Templates - Pug */
 | 
			
		||||
gulp.task('templates', function() {
 | 
			
		||||
/* Templates */
 | 
			
		||||
gulp.task('templates', function(done) {
 | 
			
		||||
    gulp.src('src/templates/**/*.pug')
 | 
			
		||||
        .pipe(gulpif(enabled.failCheck, plumber()))
 | 
			
		||||
        .pipe(gulpif(enabled.cachify, cache('templating')))
 | 
			
		||||
@@ -61,11 +69,12 @@ gulp.task('templates', function() {
 | 
			
		||||
        }))
 | 
			
		||||
        .pipe(gulp.dest(destination.pug))
 | 
			
		||||
        .pipe(gulpif(argv.livereload, livereload()));
 | 
			
		||||
    done();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/* Individual Uglified Scripts */
 | 
			
		||||
gulp.task('scripts', function() {
 | 
			
		||||
gulp.task('scripts', function(done) {
 | 
			
		||||
    gulp.src('src/scripts/*.js')
 | 
			
		||||
        .pipe(gulpif(enabled.failCheck, plumber()))
 | 
			
		||||
        .pipe(gulpif(enabled.cachify, cache('scripting')))
 | 
			
		||||
@@ -73,9 +82,48 @@ gulp.task('scripts', function() {
 | 
			
		||||
        .pipe(gulpif(enabled.uglify, uglify()))
 | 
			
		||||
        .pipe(rename({suffix: '.min'}))
 | 
			
		||||
        .pipe(gulpif(enabled.maps, sourcemaps.write(".")))
 | 
			
		||||
        .pipe(gulpif(enabled.chmod, chmod(644)))
 | 
			
		||||
        .pipe(gulpif(enabled.chmod, chmod(0o644)))
 | 
			
		||||
        .pipe(gulp.dest(destination.js))
 | 
			
		||||
        .pipe(gulpif(argv.livereload, livereload()));
 | 
			
		||||
    done();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
function browserify_base(entry) {
 | 
			
		||||
    let pathSplited = path.dirname(entry).split(path.sep);
 | 
			
		||||
    let moduleName = pathSplited[pathSplited.length - 1];
 | 
			
		||||
    return browserify({
 | 
			
		||||
        entries: [entry],
 | 
			
		||||
        standalone: 'pillar.' + moduleName,
 | 
			
		||||
    })
 | 
			
		||||
    .transform(babelify, { "presets": ["@babel/preset-env"] })
 | 
			
		||||
    .bundle()
 | 
			
		||||
    .pipe(gulpif(enabled.failCheck, plumber()))
 | 
			
		||||
    .pipe(sourceStream(path.basename(entry)))
 | 
			
		||||
    .pipe(buffer())
 | 
			
		||||
    .pipe(rename({
 | 
			
		||||
        basename: moduleName,
 | 
			
		||||
        extname: '.min.js'
 | 
			
		||||
    }));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function browserify_common() {
 | 
			
		||||
    return glob.sync('src/scripts/js/es6/common/**/init.js').map(browserify_base);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
gulp.task('scripts_browserify', function(done) {
 | 
			
		||||
    glob('src/scripts/js/es6/individual/**/init.js', function(err, files) {
 | 
			
		||||
        if(err) done(err);
 | 
			
		||||
 | 
			
		||||
        var tasks = files.map(function(entry) {
 | 
			
		||||
            return browserify_base(entry)
 | 
			
		||||
            .pipe(gulpif(enabled.maps, sourcemaps.init()))
 | 
			
		||||
            .pipe(gulpif(enabled.uglify, uglify()))
 | 
			
		||||
            .pipe(gulpif(enabled.maps, sourcemaps.write(".")))
 | 
			
		||||
            .pipe(gulp.dest(destination.js));
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        es.merge(tasks).on('end', done);
 | 
			
		||||
    })
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -83,27 +131,30 @@ gulp.task('scripts', function() {
 | 
			
		||||
 * Since it's always loaded, it's only for functions that we want site-wide.
 | 
			
		||||
 * It also includes jQuery and Bootstrap (and its dependency popper), since
 | 
			
		||||
 * the site doesn't work without it anyway.*/
 | 
			
		||||
gulp.task('scripts_concat_tutti', function() {
 | 
			
		||||
gulp.task('scripts_concat_tutti', function(done) {
 | 
			
		||||
 | 
			
		||||
    toUglify = [
 | 
			
		||||
    let 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/alert.js',
 | 
			
		||||
        source.bootstrap + 'js/dist/collapse.js',
 | 
			
		||||
        source.bootstrap + 'js/dist/dropdown.js',
 | 
			
		||||
        source.bootstrap + 'js/dist/tooltip.js',
 | 
			
		||||
        'src/scripts/tutti/**/*.js'
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    gulp.src(toUglify)
 | 
			
		||||
    es.merge(gulp.src(toUglify), ...browserify_common())
 | 
			
		||||
        .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(gulpif(enabled.chmod, chmod(644)))
 | 
			
		||||
        .pipe(gulpif(enabled.chmod, chmod(0o644)))
 | 
			
		||||
        .pipe(gulp.dest(destination.js))
 | 
			
		||||
        .pipe(gulpif(argv.livereload, livereload()));
 | 
			
		||||
    done();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -115,28 +166,30 @@ gulp.task('scripts_move_vendor', function(done) {
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    gulp.src(toMove)
 | 
			
		||||
    .pipe(gulp.dest(destination.js + '/vendor/'));
 | 
			
		||||
        .pipe(gulp.dest(destination.js + '/vendor/'));
 | 
			
		||||
    done();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
// While developing, run 'gulp watch'
 | 
			
		||||
gulp.task('watch',function() {
 | 
			
		||||
gulp.task('watch',function(done) {
 | 
			
		||||
    // Only listen for live reloads if ran with --livereload
 | 
			
		||||
    if (argv.livereload){
 | 
			
		||||
        livereload.listen();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    gulp.watch('src/styles/**/*.sass',['styles']);
 | 
			
		||||
    gulp.watch('src/templates/**/*.pug',['templates']);
 | 
			
		||||
    gulp.watch('src/scripts/*.js',['scripts']);
 | 
			
		||||
    gulp.watch('src/scripts/tutti/**/*.js',['scripts_concat_tutti']);
 | 
			
		||||
    gulp.watch('src/styles/**/*.sass',gulp.series('styles'));
 | 
			
		||||
    gulp.watch('src/templates/**/*.pug',gulp.series('templates'));
 | 
			
		||||
    gulp.watch('src/scripts/*.js',gulp.series('scripts'));
 | 
			
		||||
    gulp.watch('src/scripts/tutti/**/*.js',gulp.series('scripts_concat_tutti'));
 | 
			
		||||
    gulp.watch('src/scripts/js/**/*.js',gulp.series(['scripts_browserify', 'scripts_concat_tutti']));
 | 
			
		||||
    done();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
// Erases all generated files in output directories.
 | 
			
		||||
gulp.task('cleanup', function() {
 | 
			
		||||
    var paths = [];
 | 
			
		||||
gulp.task('cleanup', function(done) {
 | 
			
		||||
    let paths = [];
 | 
			
		||||
    for (attr in destination) {
 | 
			
		||||
        paths.push(destination[attr]);
 | 
			
		||||
    }
 | 
			
		||||
@@ -144,17 +197,20 @@ gulp.task('cleanup', function() {
 | 
			
		||||
    git.clean({ args: '-f -X ' + paths.join(' ') }, function (err) {
 | 
			
		||||
        if(err) throw err;
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    done();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
// Run 'gulp' to build everything at once
 | 
			
		||||
var tasks = [];
 | 
			
		||||
let tasks = [];
 | 
			
		||||
if (enabled.cleanup) tasks.push('cleanup');
 | 
			
		||||
gulp.task('default', tasks.concat([
 | 
			
		||||
// gulp.task('default', gulp.parallel('styles', 'templates', 'scripts', 'scripts_tutti'));
 | 
			
		||||
 | 
			
		||||
gulp.task('default', gulp.parallel(tasks.concat([
 | 
			
		||||
    'styles',
 | 
			
		||||
    'templates',
 | 
			
		||||
    'scripts',
 | 
			
		||||
    'scripts_concat_tutti',
 | 
			
		||||
    'scripts_move_vendor',
 | 
			
		||||
]));
 | 
			
		||||
    'scripts_browserify',
 | 
			
		||||
])));
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										180
									
								
								jest.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										180
									
								
								jest.config.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,180 @@
 | 
			
		||||
// For a detailed explanation regarding each configuration property, visit:
 | 
			
		||||
// https://jestjs.io/docs/en/configuration.html
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
  // All imported modules in your tests should be mocked automatically
 | 
			
		||||
  // automock: false,
 | 
			
		||||
 | 
			
		||||
  // Stop running tests after the first failure
 | 
			
		||||
  // bail: false,
 | 
			
		||||
 | 
			
		||||
  // Respect "browser" field in package.json when resolving modules
 | 
			
		||||
  // browser: false,
 | 
			
		||||
 | 
			
		||||
  // The directory where Jest should store its cached dependency information
 | 
			
		||||
  // cacheDirectory: "/tmp/jest_rs",
 | 
			
		||||
 | 
			
		||||
  // Automatically clear mock calls and instances between every test
 | 
			
		||||
  clearMocks: true,
 | 
			
		||||
 | 
			
		||||
  // Indicates whether the coverage information should be collected while executing the test
 | 
			
		||||
  // collectCoverage: false,
 | 
			
		||||
 | 
			
		||||
  // An array of glob patterns indicating a set of files for which coverage information should be collected
 | 
			
		||||
  // collectCoverageFrom: null,
 | 
			
		||||
 | 
			
		||||
  // The directory where Jest should output its coverage files
 | 
			
		||||
  // coverageDirectory: null,
 | 
			
		||||
 | 
			
		||||
  // An array of regexp pattern strings used to skip coverage collection
 | 
			
		||||
  // coveragePathIgnorePatterns: [
 | 
			
		||||
  //   "/node_modules/"
 | 
			
		||||
  // ],
 | 
			
		||||
 | 
			
		||||
  // A list of reporter names that Jest uses when writing coverage reports
 | 
			
		||||
  // coverageReporters: [
 | 
			
		||||
  //   "json",
 | 
			
		||||
  //   "text",
 | 
			
		||||
  //   "lcov",
 | 
			
		||||
  //   "clover"
 | 
			
		||||
  // ],
 | 
			
		||||
 | 
			
		||||
  // An object that configures minimum threshold enforcement for coverage results
 | 
			
		||||
  // coverageThreshold: null,
 | 
			
		||||
 | 
			
		||||
  // Make calling deprecated APIs throw helpful error messages
 | 
			
		||||
  // errorOnDeprecated: false,
 | 
			
		||||
 | 
			
		||||
  // Force coverage collection from ignored files usin a array of glob patterns
 | 
			
		||||
  // forceCoverageMatch: [],
 | 
			
		||||
 | 
			
		||||
  // A path to a module which exports an async function that is triggered once before all test suites
 | 
			
		||||
  // globalSetup: null,
 | 
			
		||||
 | 
			
		||||
  // A path to a module which exports an async function that is triggered once after all test suites
 | 
			
		||||
  // globalTeardown: null,
 | 
			
		||||
 | 
			
		||||
  // A set of global variables that need to be available in all test environments
 | 
			
		||||
  // globals: {},
 | 
			
		||||
 | 
			
		||||
  // An array of directory names to be searched recursively up from the requiring module's location
 | 
			
		||||
  // moduleDirectories: [
 | 
			
		||||
  //   "node_modules"
 | 
			
		||||
  // ],
 | 
			
		||||
 | 
			
		||||
  // An array of file extensions your modules use
 | 
			
		||||
  // moduleFileExtensions: [
 | 
			
		||||
  //   "js",
 | 
			
		||||
  //   "json",
 | 
			
		||||
  //   "jsx",
 | 
			
		||||
  //   "node"
 | 
			
		||||
  // ],
 | 
			
		||||
 | 
			
		||||
  // A map from regular expressions to module names that allow to stub out resources with a single module
 | 
			
		||||
  // moduleNameMapper: {},
 | 
			
		||||
 | 
			
		||||
  // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
 | 
			
		||||
  // modulePathIgnorePatterns: [],
 | 
			
		||||
 | 
			
		||||
  // Activates notifications for test results
 | 
			
		||||
  // notify: false,
 | 
			
		||||
 | 
			
		||||
  // An enum that specifies notification mode. Requires { notify: true }
 | 
			
		||||
  // notifyMode: "always",
 | 
			
		||||
 | 
			
		||||
  // A preset that is used as a base for Jest's configuration
 | 
			
		||||
  // preset: null,
 | 
			
		||||
 | 
			
		||||
  // Run tests from one or more projects
 | 
			
		||||
  // projects: null,
 | 
			
		||||
 | 
			
		||||
  // Use this configuration option to add custom reporters to Jest
 | 
			
		||||
  // reporters: undefined,
 | 
			
		||||
 | 
			
		||||
  // Automatically reset mock state between every test
 | 
			
		||||
  // resetMocks: false,
 | 
			
		||||
 | 
			
		||||
  // Reset the module registry before running each individual test
 | 
			
		||||
  // resetModules: false,
 | 
			
		||||
 | 
			
		||||
  // A path to a custom resolver
 | 
			
		||||
  // resolver: null,
 | 
			
		||||
 | 
			
		||||
  // Automatically restore mock state between every test
 | 
			
		||||
  // restoreMocks: false,
 | 
			
		||||
 | 
			
		||||
  // The root directory that Jest should scan for tests and modules within
 | 
			
		||||
  // rootDir: null,
 | 
			
		||||
 | 
			
		||||
  // A list of paths to directories that Jest should use to search for files in
 | 
			
		||||
  // roots: [
 | 
			
		||||
  //   "<rootDir>"
 | 
			
		||||
  // ],
 | 
			
		||||
 | 
			
		||||
  // Allows you to use a custom runner instead of Jest's default test runner
 | 
			
		||||
  // runner: "jest-runner",
 | 
			
		||||
 | 
			
		||||
  // The paths to modules that run some code to configure or set up the testing environment before each test
 | 
			
		||||
  setupFiles: ["<rootDir>/src/scripts/js/es6/test_config/test-env.js"],
 | 
			
		||||
 | 
			
		||||
  // The path to a module that runs some code to configure or set up the testing framework before each test
 | 
			
		||||
  // setupTestFrameworkScriptFile: null,
 | 
			
		||||
 | 
			
		||||
  // A list of paths to snapshot serializer modules Jest should use for snapshot testing
 | 
			
		||||
  // snapshotSerializers: [],
 | 
			
		||||
 | 
			
		||||
  // The test environment that will be used for testing
 | 
			
		||||
  testEnvironment: "jsdom",
 | 
			
		||||
 | 
			
		||||
  // Options that will be passed to the testEnvironment
 | 
			
		||||
  // testEnvironmentOptions: {},
 | 
			
		||||
 | 
			
		||||
  // Adds a location field to test results
 | 
			
		||||
  // testLocationInResults: false,
 | 
			
		||||
 | 
			
		||||
  // The glob patterns Jest uses to detect test files
 | 
			
		||||
  // testMatch: [
 | 
			
		||||
  //   "**/__tests__/**/*.js?(x)",
 | 
			
		||||
  //   "**/?(*.)+(spec|test).js?(x)"
 | 
			
		||||
  // ],
 | 
			
		||||
 | 
			
		||||
  // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
 | 
			
		||||
  // testPathIgnorePatterns: [
 | 
			
		||||
  //   "/node_modules/"
 | 
			
		||||
  // ],
 | 
			
		||||
 | 
			
		||||
  // The regexp pattern Jest uses to detect test files
 | 
			
		||||
  // testRegex: "",
 | 
			
		||||
 | 
			
		||||
  // This option allows the use of a custom results processor
 | 
			
		||||
  // testResultsProcessor: null,
 | 
			
		||||
 | 
			
		||||
  // This option allows use of a custom test runner
 | 
			
		||||
  // testRunner: "jasmine2",
 | 
			
		||||
 | 
			
		||||
  // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
 | 
			
		||||
  // testURL: "http://localhost",
 | 
			
		||||
 | 
			
		||||
  // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
 | 
			
		||||
  // timers: "real",
 | 
			
		||||
 | 
			
		||||
  // A map from regular expressions to paths to transformers
 | 
			
		||||
  // transform: null,
 | 
			
		||||
 | 
			
		||||
  // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
 | 
			
		||||
  // transformIgnorePatterns: [
 | 
			
		||||
  //   "/node_modules/"
 | 
			
		||||
  // ],
 | 
			
		||||
 | 
			
		||||
  // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
 | 
			
		||||
  // unmockedModulePathPatterns: undefined,
 | 
			
		||||
 | 
			
		||||
  // Indicates whether each individual test should be reported during the run
 | 
			
		||||
  // verbose: null,
 | 
			
		||||
 | 
			
		||||
  // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
 | 
			
		||||
  // watchPathIgnorePatterns: [],
 | 
			
		||||
 | 
			
		||||
  // Whether to use watchman for file crawling
 | 
			
		||||
  // watchman: true,
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										9672
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										9672
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										52
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										52
									
								
								package.json
									
									
									
									
									
								
							@@ -7,26 +7,40 @@
 | 
			
		||||
    "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"
 | 
			
		||||
    "@babel/core": "7.1.6",
 | 
			
		||||
    "@babel/preset-env": "7.1.6",
 | 
			
		||||
    "acorn": "5.7.3",
 | 
			
		||||
    "babel-core": "7.0.0-bridge.0",
 | 
			
		||||
    "babelify": "10.0.0",
 | 
			
		||||
    "browserify": "16.2.3",
 | 
			
		||||
    "gulp": "4.0.0",
 | 
			
		||||
    "gulp-autoprefixer": "6.0.0",
 | 
			
		||||
    "gulp-babel": "8.0.0",
 | 
			
		||||
    "gulp-cached": "1.1.1",
 | 
			
		||||
    "gulp-chmod": "2.0.0",
 | 
			
		||||
    "gulp-concat": "2.6.1",
 | 
			
		||||
    "gulp-git": "2.8.0",
 | 
			
		||||
    "gulp-if": "2.0.2",
 | 
			
		||||
    "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",
 | 
			
		||||
    "jest": "23.6.0",
 | 
			
		||||
    "minimist": "1.2.0",
 | 
			
		||||
    "vinyl-buffer": "1.0.1",
 | 
			
		||||
    "vinyl-source-stream": "2.0.0"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "bootstrap": "^4.1.3",
 | 
			
		||||
    "jquery": "^3.3.1",
 | 
			
		||||
    "popper.js": "^1.14.4",
 | 
			
		||||
    "video.js": "^7.2.2"
 | 
			
		||||
    "bootstrap": "4.1.3",
 | 
			
		||||
    "glob": "7.1.3",
 | 
			
		||||
    "jquery": "3.3.1",
 | 
			
		||||
    "popper.js": "1.14.4",
 | 
			
		||||
    "video.js": "7.2.2"
 | 
			
		||||
  },
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "test": "jest"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -712,6 +712,10 @@ class PillarServer(BlinkerCompatibleEve):
 | 
			
		||||
 | 
			
		||||
        authentication.setup_app(self)
 | 
			
		||||
 | 
			
		||||
        # Register Flask Debug Toolbar (disabled by default).
 | 
			
		||||
        from flask_debugtoolbar import DebugToolbarExtension
 | 
			
		||||
        DebugToolbarExtension(self)
 | 
			
		||||
 | 
			
		||||
        for ext in self.pillar_extensions.values():
 | 
			
		||||
            self.log.info('Setting up extension %s', ext.name)
 | 
			
		||||
            ext.setup_app(self)
 | 
			
		||||
@@ -722,6 +726,7 @@ class PillarServer(BlinkerCompatibleEve):
 | 
			
		||||
        self._config_user_caps()
 | 
			
		||||
 | 
			
		||||
        # Only enable this when debugging.
 | 
			
		||||
        # TODO(fsiddi): Consider removing this in favor of the routes tab in Flask Debug Toolbar.
 | 
			
		||||
        # self._list_routes()
 | 
			
		||||
 | 
			
		||||
    def setup_db_indices(self):
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
def setup_app(app):
 | 
			
		||||
    from . import encoding, blender_id, projects, local_auth, file_storage
 | 
			
		||||
    from . import users, nodes, latest, blender_cloud, service, activities
 | 
			
		||||
    from . import users, nodes, latest, blender_cloud, service, activities, timeline
 | 
			
		||||
    from . import organizations
 | 
			
		||||
    from . import search
 | 
			
		||||
 | 
			
		||||
@@ -11,6 +11,7 @@ def setup_app(app):
 | 
			
		||||
    local_auth.setup_app(app, url_prefix='/auth')
 | 
			
		||||
    file_storage.setup_app(app, url_prefix='/storage')
 | 
			
		||||
    latest.setup_app(app, url_prefix='/latest')
 | 
			
		||||
    timeline.setup_app(app, url_prefix='/timeline')
 | 
			
		||||
    blender_cloud.setup_app(app, url_prefix='/bcloud')
 | 
			
		||||
    users.setup_app(app, api_prefix='/users')
 | 
			
		||||
    service.setup_app(app, api_prefix='/service')
 | 
			
		||||
 
 | 
			
		||||
@@ -130,6 +130,67 @@ def _process_image(bucket: Bucket,
 | 
			
		||||
    src_file['status'] = 'complete'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _video_duration_seconds(filename: pathlib.Path) -> typing.Optional[int]:
 | 
			
		||||
    """Get the duration of a video file using ffprobe
 | 
			
		||||
    https://superuser.com/questions/650291/how-to-get-video-duration-in-seconds
 | 
			
		||||
 | 
			
		||||
    :param filename: file path to video
 | 
			
		||||
    :return: video duration in seconds
 | 
			
		||||
    """
 | 
			
		||||
    import subprocess
 | 
			
		||||
 | 
			
		||||
    def run(cli_args):
 | 
			
		||||
        if log.isEnabledFor(logging.INFO):
 | 
			
		||||
            import shlex
 | 
			
		||||
            cmd = ' '.join(shlex.quote(s) for s in cli_args)
 | 
			
		||||
            log.info('Calling %s', cmd)
 | 
			
		||||
 | 
			
		||||
        ffprobe = subprocess.run(
 | 
			
		||||
            cli_args,
 | 
			
		||||
            stdin=subprocess.DEVNULL,
 | 
			
		||||
            stdout=subprocess.PIPE,
 | 
			
		||||
            stderr=subprocess.STDOUT,
 | 
			
		||||
            timeout=10,  # seconds
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        if ffprobe.returncode:
 | 
			
		||||
            import shlex
 | 
			
		||||
            cmd = ' '.join(shlex.quote(s) for s in cli_args)
 | 
			
		||||
            log.error('Error running %s: stopped with return code %i',
 | 
			
		||||
                      cmd, ffprobe.returncode)
 | 
			
		||||
            log.error('Output was: %s', ffprobe.stdout)
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            return int(float(ffprobe.stdout))
 | 
			
		||||
        except ValueError as e:
 | 
			
		||||
            log.exception('ffprobe produced invalid number: %s', ffprobe.stdout)
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
    ffprobe_from_container_args = [
 | 
			
		||||
        current_app.config['BIN_FFPROBE'],
 | 
			
		||||
        '-v', 'error',
 | 
			
		||||
        '-show_entries', 'format=duration',
 | 
			
		||||
        '-of', 'default=noprint_wrappers=1:nokey=1',
 | 
			
		||||
        str(filename),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    ffprobe_from_stream_args = [
 | 
			
		||||
        current_app.config['BIN_FFPROBE'],
 | 
			
		||||
        '-v', 'error',
 | 
			
		||||
        '-hide_banner',
 | 
			
		||||
        '-select_streams', 'v:0',  # we only care about the first video stream
 | 
			
		||||
        '-show_entries', 'stream=duration',
 | 
			
		||||
        '-of', 'default=noprint_wrappers=1:nokey=1',
 | 
			
		||||
        str(filename),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    duration = run(ffprobe_from_stream_args) or\
 | 
			
		||||
               run(ffprobe_from_container_args) or\
 | 
			
		||||
               None
 | 
			
		||||
    return duration
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _video_size_pixels(filename: pathlib.Path) -> typing.Tuple[int, int]:
 | 
			
		||||
    """Figures out the size (in pixels) of the video file.
 | 
			
		||||
 | 
			
		||||
@@ -220,8 +281,10 @@ def _process_video(gcs,
 | 
			
		||||
    # by determining the video size here we already have this information in the file
 | 
			
		||||
    # document before Zencoder calls our notification URL. It also opens up possibilities
 | 
			
		||||
    # for other encoding backends that don't support this functionality.
 | 
			
		||||
    video_width, video_height = _video_size_pixels(pathlib.Path(local_file.name))
 | 
			
		||||
    video_path = pathlib.Path(local_file.name)
 | 
			
		||||
    video_width, video_height = _video_size_pixels(video_path)
 | 
			
		||||
    capped_video_width, capped_video_height = _video_cap_at_1080(video_width, video_height)
 | 
			
		||||
    video_duration = _video_duration_seconds(video_path)
 | 
			
		||||
 | 
			
		||||
    # Create variations
 | 
			
		||||
    root, _ = os.path.splitext(src_file['file_path'])
 | 
			
		||||
@@ -234,12 +297,13 @@ def _process_video(gcs,
 | 
			
		||||
        content_type='video/{}'.format(v),
 | 
			
		||||
        file_path='{}-{}.{}'.format(root, v, v),
 | 
			
		||||
        size='',
 | 
			
		||||
        duration=0,
 | 
			
		||||
        width=capped_video_width,
 | 
			
		||||
        height=capped_video_height,
 | 
			
		||||
        length=0,
 | 
			
		||||
        md5='',
 | 
			
		||||
    )
 | 
			
		||||
    if video_duration:
 | 
			
		||||
        file_variation['duration'] = video_duration
 | 
			
		||||
    # Append file variation. Originally mp4 and webm were the available options,
 | 
			
		||||
    # that's why we build a list.
 | 
			
		||||
    src_file['variations'].append(file_variation)
 | 
			
		||||
 
 | 
			
		||||
@@ -29,7 +29,6 @@ def latest_nodes(db_filter, projection, limit):
 | 
			
		||||
    proj = {
 | 
			
		||||
        '_created': 1,
 | 
			
		||||
        '_updated': 1,
 | 
			
		||||
        'user.full_name': 1,
 | 
			
		||||
        'project._id': 1,
 | 
			
		||||
        'project.url': 1,
 | 
			
		||||
        'project.name': 1,
 | 
			
		||||
@@ -70,6 +69,7 @@ def latest_assets():
 | 
			
		||||
                          {'name': 1, 'node_type': 1,
 | 
			
		||||
                           'parent': 1, 'picture': 1, 'properties.status': 1,
 | 
			
		||||
                           'properties.content_type': 1,
 | 
			
		||||
                           'properties.duration_seconds': 1,
 | 
			
		||||
                           'permissions.world': 1},
 | 
			
		||||
                          12)
 | 
			
		||||
 | 
			
		||||
@@ -80,7 +80,7 @@ def latest_assets():
 | 
			
		||||
def latest_comments():
 | 
			
		||||
    latest = latest_nodes({'node_type': 'comment',
 | 
			
		||||
                           'properties.status': 'published'},
 | 
			
		||||
                          {'parent': 1,
 | 
			
		||||
                          {'parent': 1, 'user.full_name': 1,
 | 
			
		||||
                           'properties.content': 1, 'node_type': 1,
 | 
			
		||||
                           'properties.status': 1,
 | 
			
		||||
                           'properties.is_reply': 1},
 | 
			
		||||
 
 | 
			
		||||
@@ -24,6 +24,10 @@ node_type_asset = {
 | 
			
		||||
        'content_type': {
 | 
			
		||||
            'type': 'string'
 | 
			
		||||
        },
 | 
			
		||||
        # The duration of a video asset in seconds.
 | 
			
		||||
        'duration_seconds': {
 | 
			
		||||
            'type': 'integer'
 | 
			
		||||
        },
 | 
			
		||||
        # We point to the original file (and use it to extract any relevant
 | 
			
		||||
        # variation useful for our scope).
 | 
			
		||||
        'file': _file_embedded_schema,
 | 
			
		||||
@@ -58,6 +62,7 @@ node_type_asset = {
 | 
			
		||||
    },
 | 
			
		||||
    'form_schema': {
 | 
			
		||||
        'content_type': {'visible': False},
 | 
			
		||||
        'duration_seconds': {'visible': False},
 | 
			
		||||
        'order': {'visible': False},
 | 
			
		||||
        'tags': {'visible': False},
 | 
			
		||||
        'categories': {'visible': False},
 | 
			
		||||
 
 | 
			
		||||
@@ -1,14 +1,15 @@
 | 
			
		||||
import base64
 | 
			
		||||
import datetime
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
import pymongo.errors
 | 
			
		||||
import werkzeug.exceptions as wz_exceptions
 | 
			
		||||
from flask import current_app, Blueprint, request
 | 
			
		||||
 | 
			
		||||
from pillar.api.nodes import hooks
 | 
			
		||||
from pillar.api.nodes.hooks import short_link_info
 | 
			
		||||
from pillar.api.nodes import eve_hooks
 | 
			
		||||
from pillar.api.utils import str2id, jsonify
 | 
			
		||||
from pillar.api.utils.authorization import check_permissions, require_login
 | 
			
		||||
from pillar.web.utils import pretty_date
 | 
			
		||||
 | 
			
		||||
log = logging.getLogger(__name__)
 | 
			
		||||
blueprint = Blueprint('nodes_api', __name__)
 | 
			
		||||
@@ -47,7 +48,7 @@ def share_node(node_id):
 | 
			
		||||
        else:
 | 
			
		||||
            return '', 204
 | 
			
		||||
 | 
			
		||||
    return jsonify(short_link_info(short_code), status=status)
 | 
			
		||||
    return jsonify(eve_hooks.short_link_info(short_code), status=status)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@blueprint.route('/tagged/')
 | 
			
		||||
@@ -64,6 +65,13 @@ def tagged(tag=''):
 | 
			
		||||
    # Build the (cached) list of tagged nodes
 | 
			
		||||
    agg_list = _tagged(tag)
 | 
			
		||||
 | 
			
		||||
    for node in agg_list:
 | 
			
		||||
        if node['properties'].get('duration_seconds'):
 | 
			
		||||
            node['properties']['duration'] = datetime.timedelta(seconds=node['properties']['duration_seconds'])
 | 
			
		||||
 | 
			
		||||
        if node.get('_created') is not None:
 | 
			
		||||
            node['pretty_created'] = pretty_date(node['_created'])
 | 
			
		||||
 | 
			
		||||
    # If the user is anonymous, no more information is needed and we return
 | 
			
		||||
    if current_user.is_anonymous:
 | 
			
		||||
        return jsonify(agg_list)
 | 
			
		||||
@@ -100,11 +108,16 @@ def _tagged(tag: str):
 | 
			
		||||
            'foreignField': '_id',
 | 
			
		||||
            'as': '_project',
 | 
			
		||||
        }},
 | 
			
		||||
        {'$unwind': '$_project'},
 | 
			
		||||
        {'$match': {'_project.is_private': False}},
 | 
			
		||||
        {'$addFields': {
 | 
			
		||||
            'project._id': '$_project._id',
 | 
			
		||||
            'project.name': '$_project.name',
 | 
			
		||||
            'project.url': '$_project.url',
 | 
			
		||||
        }},
 | 
			
		||||
 | 
			
		||||
        # Don't return the entire project for each node.
 | 
			
		||||
        # Don't return the entire project/file for each node.
 | 
			
		||||
        {'$project': {'_project': False}},
 | 
			
		||||
 | 
			
		||||
        {'$sort': {'_created': -1}}
 | 
			
		||||
    ])
 | 
			
		||||
 | 
			
		||||
@@ -195,26 +208,26 @@ def setup_app(app, url_prefix):
 | 
			
		||||
    from . import patch
 | 
			
		||||
    patch.setup_app(app, url_prefix=url_prefix)
 | 
			
		||||
 | 
			
		||||
    app.on_fetched_item_nodes += hooks.before_returning_node
 | 
			
		||||
    app.on_fetched_resource_nodes += hooks.before_returning_nodes
 | 
			
		||||
    app.on_fetched_item_nodes += eve_hooks.before_returning_node
 | 
			
		||||
    app.on_fetched_resource_nodes += eve_hooks.before_returning_nodes
 | 
			
		||||
 | 
			
		||||
    app.on_replace_nodes += 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_replace_nodes += eve_hooks.before_replacing_node
 | 
			
		||||
    app.on_replace_nodes += eve_hooks.parse_markdown
 | 
			
		||||
    app.on_replace_nodes += eve_hooks.texture_sort_files
 | 
			
		||||
    app.on_replace_nodes += eve_hooks.deduct_content_type_and_duration
 | 
			
		||||
    app.on_replace_nodes += eve_hooks.node_set_default_picture
 | 
			
		||||
    app.on_replaced_nodes += eve_hooks.after_replacing_node
 | 
			
		||||
 | 
			
		||||
    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_insert_nodes += eve_hooks.before_inserting_nodes
 | 
			
		||||
    app.on_insert_nodes += eve_hooks.parse_markdowns
 | 
			
		||||
    app.on_insert_nodes += eve_hooks.nodes_deduct_content_type_and_duration
 | 
			
		||||
    app.on_insert_nodes += eve_hooks.nodes_set_default_picture
 | 
			
		||||
    app.on_insert_nodes += eve_hooks.textures_sort_files
 | 
			
		||||
    app.on_inserted_nodes += eve_hooks.after_inserting_nodes
 | 
			
		||||
 | 
			
		||||
    app.on_update_nodes += hooks.texture_sort_files
 | 
			
		||||
    app.on_update_nodes += eve_hooks.texture_sort_files
 | 
			
		||||
 | 
			
		||||
    app.on_delete_item_nodes += hooks.before_deleting_node
 | 
			
		||||
    app.on_deleted_item_nodes += hooks.after_deleting_node
 | 
			
		||||
    app.on_delete_item_nodes += eve_hooks.before_deleting_node
 | 
			
		||||
    app.on_deleted_item_nodes += eve_hooks.after_deleting_node
 | 
			
		||||
 | 
			
		||||
    app.register_api_blueprint(blueprint, url_prefix=url_prefix)
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,7 @@ from flask import current_app
 | 
			
		||||
import werkzeug.exceptions as wz_exceptions
 | 
			
		||||
 | 
			
		||||
from pillar.api.utils import authorization, authentication, jsonify
 | 
			
		||||
from pillar.api.utils.rating import confidence
 | 
			
		||||
 | 
			
		||||
from . import register_patch_handler
 | 
			
		||||
 | 
			
		||||
@@ -25,6 +26,13 @@ def patch_comment(node_id, patch):
 | 
			
		||||
        assert patch['op'] == 'edit', 'Invalid patch operation %s' % patch['op']
 | 
			
		||||
        result, node = edit_comment(user_id, node_id, patch)
 | 
			
		||||
 | 
			
		||||
    # Calculate and update confidence.
 | 
			
		||||
    rating_confidence = confidence(
 | 
			
		||||
        node['properties']['rating_positive'], node['properties']['rating_negative'])
 | 
			
		||||
    current_app.data.driver.db['nodes'].update_one(
 | 
			
		||||
        {'_id': node_id},
 | 
			
		||||
        {'$set': {'properties.confidence': rating_confidence}})
 | 
			
		||||
 | 
			
		||||
    return jsonify({'_status': 'OK',
 | 
			
		||||
                    'result': result,
 | 
			
		||||
                    'properties': node['properties']
 | 
			
		||||
 
 | 
			
		||||
@@ -1,14 +1,17 @@
 | 
			
		||||
import collections
 | 
			
		||||
import functools
 | 
			
		||||
import logging
 | 
			
		||||
import urllib.parse
 | 
			
		||||
 | 
			
		||||
from bson import ObjectId
 | 
			
		||||
from flask import current_app
 | 
			
		||||
from werkzeug import exceptions as wz_exceptions
 | 
			
		||||
 | 
			
		||||
from pillar import current_app
 | 
			
		||||
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 import random_etag
 | 
			
		||||
from pillar.api.utils.authorization import check_permissions
 | 
			
		||||
 | 
			
		||||
log = logging.getLogger(__name__)
 | 
			
		||||
@@ -162,7 +165,7 @@ def after_inserting_nodes(items):
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def deduct_content_type(node_doc, original=None):
 | 
			
		||||
def deduct_content_type_and_duration(node_doc, original=None):
 | 
			
		||||
    """Deduct the content type from the attached file, if any."""
 | 
			
		||||
 | 
			
		||||
    if node_doc['node_type'] != 'asset':
 | 
			
		||||
@@ -181,7 +184,8 @@ def deduct_content_type(node_doc, original=None):
 | 
			
		||||
 | 
			
		||||
    files = current_app.data.driver.db['files']
 | 
			
		||||
    file_doc = files.find_one({'_id': file_id},
 | 
			
		||||
                              {'content_type': 1})
 | 
			
		||||
                              {'content_type': 1,
 | 
			
		||||
                               'variations': 1})
 | 
			
		||||
    if not file_doc:
 | 
			
		||||
        log.warning('deduct_content_type: Node %s refers to non-existing file %s, rejecting.',
 | 
			
		||||
                    node_id, file_id)
 | 
			
		||||
@@ -198,10 +202,17 @@ def deduct_content_type(node_doc, original=None):
 | 
			
		||||
 | 
			
		||||
    node_doc['properties']['content_type'] = content_type
 | 
			
		||||
 | 
			
		||||
    if content_type == 'video':
 | 
			
		||||
        duration = file_doc['variations'][0].get('duration')
 | 
			
		||||
        if duration:
 | 
			
		||||
            node_doc['properties']['duration_seconds'] = duration
 | 
			
		||||
        else:
 | 
			
		||||
            log.warning('Video file %s has no duration', file_id)
 | 
			
		||||
 | 
			
		||||
def nodes_deduct_content_type(nodes):
 | 
			
		||||
 | 
			
		||||
def nodes_deduct_content_type_and_duration(nodes):
 | 
			
		||||
    for node in nodes:
 | 
			
		||||
        deduct_content_type(node)
 | 
			
		||||
        deduct_content_type_and_duration(node)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def node_set_default_picture(node, original=None):
 | 
			
		||||
@@ -243,6 +254,44 @@ def nodes_set_default_picture(nodes):
 | 
			
		||||
 | 
			
		||||
def before_deleting_node(node: dict):
 | 
			
		||||
    check_permissions('nodes', node, 'DELETE')
 | 
			
		||||
    remove_project_references(node)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def remove_project_references(node):
 | 
			
		||||
    project_id = node.get('project')
 | 
			
		||||
    if not project_id:
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    node_id = node['_id']
 | 
			
		||||
    log.info('Removing references to node %s from project %s', node_id, project_id)
 | 
			
		||||
 | 
			
		||||
    projects_col = current_app.db('projects')
 | 
			
		||||
    project = projects_col.find_one({'_id': project_id})
 | 
			
		||||
    updates = collections.defaultdict(dict)
 | 
			
		||||
 | 
			
		||||
    if project.get('header_node') == node_id:
 | 
			
		||||
        updates['$unset']['header_node'] = node_id
 | 
			
		||||
 | 
			
		||||
    project_reference_lists = ('nodes_blog', 'nodes_featured', 'nodes_latest')
 | 
			
		||||
    for list_name in project_reference_lists:
 | 
			
		||||
        references = project.get(list_name)
 | 
			
		||||
        if not references:
 | 
			
		||||
            continue
 | 
			
		||||
        try:
 | 
			
		||||
            references.remove(node_id)
 | 
			
		||||
        except ValueError:
 | 
			
		||||
            continue
 | 
			
		||||
 | 
			
		||||
        updates['$set'][list_name] = references
 | 
			
		||||
 | 
			
		||||
    if not updates:
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    updates['$set']['_etag'] = random_etag()
 | 
			
		||||
    result = projects_col.update_one({'_id': project_id}, updates)
 | 
			
		||||
    if result.modified_count != 1:
 | 
			
		||||
        log.warning('Removing references to node %s from project %s resulted in %d modified documents (expected 1)',
 | 
			
		||||
                    node_id, project_id, result.modified_count)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def after_deleting_node(item):
 | 
			
		||||
@@ -81,6 +81,7 @@ class Node(es.DocType):
 | 
			
		||||
        fields={
 | 
			
		||||
            'id': es.Keyword(),
 | 
			
		||||
            'name': es.Keyword(),
 | 
			
		||||
            'url': es.Keyword(),
 | 
			
		||||
        }
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
@@ -153,18 +154,21 @@ def create_doc_from_node_data(node_to_index: dict) -> typing.Optional[Node]:
 | 
			
		||||
    doc.objectID = str(node_to_index['objectID'])
 | 
			
		||||
    doc.node_type = node_to_index['node_type']
 | 
			
		||||
    doc.name = node_to_index['name']
 | 
			
		||||
    doc.description = node_to_index.get('description')
 | 
			
		||||
    doc.user.id = str(node_to_index['user']['_id'])
 | 
			
		||||
    doc.user.name = node_to_index['user']['full_name']
 | 
			
		||||
    doc.project.id = str(node_to_index['project']['_id'])
 | 
			
		||||
    doc.project.name = node_to_index['project']['name']
 | 
			
		||||
    doc.project.url = node_to_index['project']['url']
 | 
			
		||||
 | 
			
		||||
    if node_to_index['node_type'] == 'asset':
 | 
			
		||||
        doc.media = node_to_index['media']
 | 
			
		||||
 | 
			
		||||
    doc.picture = node_to_index.get('picture')
 | 
			
		||||
    doc.picture = str(node_to_index.get('picture'))
 | 
			
		||||
 | 
			
		||||
    doc.tags = node_to_index.get('tags')
 | 
			
		||||
    doc.license_notes = node_to_index.get('license_notes')
 | 
			
		||||
    doc.is_free = node_to_index.get('is_free')
 | 
			
		||||
 | 
			
		||||
    doc.created_at = node_to_index['created']
 | 
			
		||||
    doc.updated_at = node_to_index['updated']
 | 
			
		||||
 
 | 
			
		||||
@@ -3,16 +3,18 @@ import logging
 | 
			
		||||
import typing
 | 
			
		||||
 | 
			
		||||
from elasticsearch import Elasticsearch
 | 
			
		||||
from elasticsearch_dsl import Search, Q
 | 
			
		||||
from elasticsearch_dsl import Search, Q, MultiSearch
 | 
			
		||||
from elasticsearch_dsl.query import Query
 | 
			
		||||
 | 
			
		||||
from pillar import current_app
 | 
			
		||||
 | 
			
		||||
log = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
NODE_AGG_TERMS = ['node_type', 'media', 'tags', 'is_free']
 | 
			
		||||
BOOLEAN_TERMS = ['is_free']
 | 
			
		||||
NODE_AGG_TERMS = ['node_type', 'media', 'tags', *BOOLEAN_TERMS]
 | 
			
		||||
USER_AGG_TERMS = ['roles', ]
 | 
			
		||||
ITEMS_PER_PAGE = 10
 | 
			
		||||
USER_SOURCE_INCLUDE = ['full_name', 'objectID', 'username']
 | 
			
		||||
 | 
			
		||||
# Will be set in setup_app()
 | 
			
		||||
client: Elasticsearch = None
 | 
			
		||||
@@ -27,26 +29,25 @@ def add_aggs_to_search(search, agg_terms):
 | 
			
		||||
        search.aggs.bucket(term, 'terms', field=term)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def make_must(must: list, terms: dict) -> list:
 | 
			
		||||
def make_filter(must: list, terms: dict) -> list:
 | 
			
		||||
    """ Given term parameters append must queries to the must list """
 | 
			
		||||
 | 
			
		||||
    for field, value in terms.items():
 | 
			
		||||
        if value:
 | 
			
		||||
            must.append({'match': {field: value}})
 | 
			
		||||
        if value not in (None, ''):
 | 
			
		||||
            must.append({'term': {field: value}})
 | 
			
		||||
 | 
			
		||||
    return must
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def nested_bool(must: list, should: list, terms: dict, *, index_alias: str) -> Search:
 | 
			
		||||
def nested_bool(filters: list, should: list, terms: dict, *, index_alias: str) -> Search:
 | 
			
		||||
    """
 | 
			
		||||
    Create a nested bool, where the aggregation selection is a must.
 | 
			
		||||
 | 
			
		||||
    :param index_alias: 'USER' or 'NODE', see ELASTIC_INDICES config.
 | 
			
		||||
    """
 | 
			
		||||
    must = make_must(must, terms)
 | 
			
		||||
    filters = make_filter(filters, terms)
 | 
			
		||||
    bool_query = Q('bool', should=should)
 | 
			
		||||
    must.append(bool_query)
 | 
			
		||||
    bool_query = Q('bool', must=must)
 | 
			
		||||
    bool_query = Q('bool', must=bool_query, filter=filters)
 | 
			
		||||
 | 
			
		||||
    index = current_app.config['ELASTIC_INDICES'][index_alias]
 | 
			
		||||
    search = Search(using=client, index=index)
 | 
			
		||||
@@ -55,12 +56,34 @@ def nested_bool(must: list, should: list, terms: dict, *, index_alias: str) -> S
 | 
			
		||||
    return search
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def do_multi_node_search(queries: typing.List[dict]) -> typing.List[dict]:
 | 
			
		||||
    """
 | 
			
		||||
    Given user query input and term refinements
 | 
			
		||||
    search for public published nodes
 | 
			
		||||
    """
 | 
			
		||||
    search = create_multi_node_search(queries)
 | 
			
		||||
    return _execute_multi(search)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def do_node_search(query: str, terms: dict, page: int, project_id: str='') -> dict:
 | 
			
		||||
    """
 | 
			
		||||
    Given user query input and term refinements
 | 
			
		||||
    search for public published nodes
 | 
			
		||||
    """
 | 
			
		||||
    search = create_node_search(query, terms, page, project_id)
 | 
			
		||||
    return _execute(search)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def create_multi_node_search(queries: typing.List[dict]) -> MultiSearch:
 | 
			
		||||
    search = MultiSearch(using=client)
 | 
			
		||||
    for q in queries:
 | 
			
		||||
        search = search.add(create_node_search(**q))
 | 
			
		||||
 | 
			
		||||
    return search
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def create_node_search(query: str, terms: dict, page: int, project_id: str='') -> Search:
 | 
			
		||||
    terms = _transform_terms(terms)
 | 
			
		||||
    should = [
 | 
			
		||||
        Q('match', name=query),
 | 
			
		||||
 | 
			
		||||
@@ -71,52 +94,30 @@ def do_node_search(query: str, terms: dict, page: int, project_id: str='') -> di
 | 
			
		||||
        Q('term', media=query),
 | 
			
		||||
        Q('term', tags=query),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    must = []
 | 
			
		||||
    filters = []
 | 
			
		||||
    if project_id:
 | 
			
		||||
        must.append({'term': {'project.id': project_id}})
 | 
			
		||||
 | 
			
		||||
        filters.append({'term': {'project.id': project_id}})
 | 
			
		||||
    if not query:
 | 
			
		||||
        should = []
 | 
			
		||||
 | 
			
		||||
    search = nested_bool(must, should, terms, index_alias='NODE')
 | 
			
		||||
    search = nested_bool(filters, should, terms, index_alias='NODE')
 | 
			
		||||
    if not query:
 | 
			
		||||
        search = search.sort('-created_at')
 | 
			
		||||
    add_aggs_to_search(search, NODE_AGG_TERMS)
 | 
			
		||||
    search = paginate(search, page)
 | 
			
		||||
 | 
			
		||||
    if log.isEnabledFor(logging.DEBUG):
 | 
			
		||||
        log.debug(json.dumps(search.to_dict(), indent=4))
 | 
			
		||||
 | 
			
		||||
    response = search.execute()
 | 
			
		||||
 | 
			
		||||
    if log.isEnabledFor(logging.DEBUG):
 | 
			
		||||
        log.debug(json.dumps(response.to_dict(), indent=4))
 | 
			
		||||
 | 
			
		||||
    return response.to_dict()
 | 
			
		||||
    return search
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def do_user_search(query: str, terms: dict, page: int) -> dict:
 | 
			
		||||
    """ return user objects represented in elasicsearch result dict"""
 | 
			
		||||
 | 
			
		||||
    must, should = _common_user_search(query)
 | 
			
		||||
    search = nested_bool(must, should, terms, index_alias='USER')
 | 
			
		||||
    add_aggs_to_search(search, USER_AGG_TERMS)
 | 
			
		||||
    search = paginate(search, page)
 | 
			
		||||
 | 
			
		||||
    if log.isEnabledFor(logging.DEBUG):
 | 
			
		||||
        log.debug(json.dumps(search.to_dict(), indent=4))
 | 
			
		||||
 | 
			
		||||
    response = search.execute()
 | 
			
		||||
 | 
			
		||||
    if log.isEnabledFor(logging.DEBUG):
 | 
			
		||||
        log.debug(json.dumps(response.to_dict(), indent=4))
 | 
			
		||||
 | 
			
		||||
    return response.to_dict()
 | 
			
		||||
    search = create_user_search(query, terms, page)
 | 
			
		||||
    return _execute(search)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _common_user_search(query: str) -> (typing.List[Query], typing.List[Query]):
 | 
			
		||||
    """Construct (must,shoud) for regular + admin user search."""
 | 
			
		||||
    """Construct (filter,should) for regular + admin user search."""
 | 
			
		||||
    if not query:
 | 
			
		||||
        return [], []
 | 
			
		||||
 | 
			
		||||
@@ -144,8 +145,31 @@ def do_user_search_admin(query: str, terms: dict, page: int) -> dict:
 | 
			
		||||
    search all user fields and provide aggregation information
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    must, should = _common_user_search(query)
 | 
			
		||||
    search = create_user_admin_search(query, terms, page)
 | 
			
		||||
    return _execute(search)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _execute(search: Search) -> dict:
 | 
			
		||||
    if log.isEnabledFor(logging.DEBUG):
 | 
			
		||||
        log.debug(json.dumps(search.to_dict(), indent=4))
 | 
			
		||||
    resp = search.execute()
 | 
			
		||||
    if log.isEnabledFor(logging.DEBUG):
 | 
			
		||||
        log.debug(json.dumps(resp.to_dict(), indent=4))
 | 
			
		||||
    return resp.to_dict()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _execute_multi(search: typing.List[Search]) -> typing.List[dict]:
 | 
			
		||||
    if log.isEnabledFor(logging.DEBUG):
 | 
			
		||||
        log.debug(json.dumps(search.to_dict(), indent=4))
 | 
			
		||||
    resp = search.execute()
 | 
			
		||||
    if log.isEnabledFor(logging.DEBUG):
 | 
			
		||||
        log.debug(json.dumps(resp.to_dict(), indent=4))
 | 
			
		||||
    return [r.to_dict() for r in resp]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def create_user_admin_search(query: str, terms: dict, page: int) -> Search:
 | 
			
		||||
    terms = _transform_terms(terms)
 | 
			
		||||
    filters, should = _common_user_search(query)
 | 
			
		||||
    if query:
 | 
			
		||||
        # We most likely got and id field. we should find it.
 | 
			
		||||
        if len(query) == len('563aca02c379cf0005e8e17d'):
 | 
			
		||||
@@ -155,26 +179,34 @@ def do_user_search_admin(query: str, terms: dict, page: int) -> dict:
 | 
			
		||||
                    'boost': 100,  # how much more it counts for the score
 | 
			
		||||
                }
 | 
			
		||||
            }})
 | 
			
		||||
 | 
			
		||||
    search = nested_bool(must, should, terms, index_alias='USER')
 | 
			
		||||
    search = nested_bool(filters, should, terms, index_alias='USER')
 | 
			
		||||
    add_aggs_to_search(search, USER_AGG_TERMS)
 | 
			
		||||
    search = paginate(search, page)
 | 
			
		||||
    return search
 | 
			
		||||
 | 
			
		||||
    if log.isEnabledFor(logging.DEBUG):
 | 
			
		||||
        log.debug(json.dumps(search.to_dict(), indent=4))
 | 
			
		||||
 | 
			
		||||
    response = search.execute()
 | 
			
		||||
 | 
			
		||||
    if log.isEnabledFor(logging.DEBUG):
 | 
			
		||||
        log.debug(json.dumps(response.to_dict(), indent=4))
 | 
			
		||||
 | 
			
		||||
    return response.to_dict()
 | 
			
		||||
def create_user_search(query: str, terms: dict, page: int) -> Search:
 | 
			
		||||
    search = create_user_admin_search(query, terms, page)
 | 
			
		||||
    return search.source(include=USER_SOURCE_INCLUDE)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def paginate(search: Search, page_idx: int) -> Search:
 | 
			
		||||
    return search[page_idx * ITEMS_PER_PAGE:(page_idx + 1) * ITEMS_PER_PAGE]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _transform_terms(terms: dict) -> dict:
 | 
			
		||||
    """
 | 
			
		||||
    Ugly hack! Elastic uses 1/0 for boolean values in its aggregate response,
 | 
			
		||||
    but expects true/false in queries.
 | 
			
		||||
    """
 | 
			
		||||
    transformed = terms.copy()
 | 
			
		||||
    for t in BOOLEAN_TERMS:
 | 
			
		||||
        orig = transformed.get(t)
 | 
			
		||||
        if orig in ('1', '0'):
 | 
			
		||||
            transformed[t] = bool(int(orig))
 | 
			
		||||
    return transformed
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def setup_app(app):
 | 
			
		||||
    global client
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -18,7 +18,7 @@ TERMS = [
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _term_filters() -> dict:
 | 
			
		||||
def _term_filters(args) -> dict:
 | 
			
		||||
    """
 | 
			
		||||
    Check if frontent wants to filter stuff
 | 
			
		||||
    on specific fields AKA facets
 | 
			
		||||
@@ -26,35 +26,53 @@ def _term_filters() -> dict:
 | 
			
		||||
    return mapping with term field name
 | 
			
		||||
    and provided user term value
 | 
			
		||||
    """
 | 
			
		||||
    return {term: request.args.get(term, '') for term in TERMS}
 | 
			
		||||
    return {term: args.get(term, '') for term in TERMS}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _page_index() -> int:
 | 
			
		||||
def _page_index(page) -> int:
 | 
			
		||||
    """Return the page index from the query string."""
 | 
			
		||||
    try:
 | 
			
		||||
        page_idx = int(request.args.get('page') or '0')
 | 
			
		||||
        page_idx = int(page)
 | 
			
		||||
    except TypeError:
 | 
			
		||||
        log.info('invalid page number %r received', request.args.get('page'))
 | 
			
		||||
        raise wz_exceptions.BadRequest()
 | 
			
		||||
    return page_idx
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@blueprint_search.route('/')
 | 
			
		||||
@blueprint_search.route('/', methods=['GET'])
 | 
			
		||||
def search_nodes():
 | 
			
		||||
    searchword = request.args.get('q', '')
 | 
			
		||||
    project_id = request.args.get('project', '')
 | 
			
		||||
    terms = _term_filters()
 | 
			
		||||
    page_idx = _page_index()
 | 
			
		||||
    terms = _term_filters(request.args)
 | 
			
		||||
    page_idx = _page_index(request.args.get('page', 0))
 | 
			
		||||
 | 
			
		||||
    result = queries.do_node_search(searchword, terms, page_idx, project_id)
 | 
			
		||||
    return jsonify(result)
 | 
			
		||||
 | 
			
		||||
@blueprint_search.route('/multisearch', methods=['GET'])
 | 
			
		||||
def multi_search_nodes():
 | 
			
		||||
    import json
 | 
			
		||||
    if len(request.args) != 1:
 | 
			
		||||
        log.info(f'Expected 1 argument, received {len(request.args)}')
 | 
			
		||||
 | 
			
		||||
    json_obj = json.loads([a for a in request.args][0])
 | 
			
		||||
    q = []
 | 
			
		||||
    for row in json_obj:
 | 
			
		||||
        q.append({
 | 
			
		||||
            'query': row.get('q', ''),
 | 
			
		||||
            'project_id': row.get('project', ''),
 | 
			
		||||
            'terms': _term_filters(row),
 | 
			
		||||
            'page': _page_index(row.get('page', 0))
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
    result = queries.do_multi_node_search(q)
 | 
			
		||||
    return jsonify(result)
 | 
			
		||||
 | 
			
		||||
@blueprint_search.route('/user')
 | 
			
		||||
def search_user():
 | 
			
		||||
    searchword = request.args.get('q', '')
 | 
			
		||||
    terms = _term_filters()
 | 
			
		||||
    page_idx = _page_index()
 | 
			
		||||
    terms = _term_filters(request.args)
 | 
			
		||||
    page_idx = _page_index(request.args.get('page', 0))
 | 
			
		||||
    # result is the raw elasticseach output.
 | 
			
		||||
    # we need to filter fields in case of user objects.
 | 
			
		||||
 | 
			
		||||
@@ -65,27 +83,6 @@ def search_user():
 | 
			
		||||
        resp.status_code = 500
 | 
			
		||||
        return resp
 | 
			
		||||
 | 
			
		||||
    # filter sensitive stuff
 | 
			
		||||
    # we only need. objectID, full_name, username
 | 
			
		||||
    hits = result.get('hits', {})
 | 
			
		||||
 | 
			
		||||
    new_hits = []
 | 
			
		||||
 | 
			
		||||
    for hit in hits.get('hits'):
 | 
			
		||||
        source = hit['_source']
 | 
			
		||||
        single_hit = {
 | 
			
		||||
            '_source': {
 | 
			
		||||
                'objectID': source.get('objectID'),
 | 
			
		||||
                'username': source.get('username'),
 | 
			
		||||
                'full_name': source.get('full_name'),
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        new_hits.append(single_hit)
 | 
			
		||||
 | 
			
		||||
    # replace search result with safe subset
 | 
			
		||||
    result['hits']['hits'] = new_hits
 | 
			
		||||
 | 
			
		||||
    return jsonify(result)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -97,8 +94,8 @@ def search_user_admin():
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    searchword = request.args.get('q', '')
 | 
			
		||||
    terms = _term_filters()
 | 
			
		||||
    page_idx = _page_index()
 | 
			
		||||
    terms = _term_filters(request.args)
 | 
			
		||||
    page_idx = _page_index(_page_index(request.args.get('page', 0)))
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        result = queries.do_user_search_admin(searchword, terms, page_idx)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										373
									
								
								pillar/api/timeline.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										373
									
								
								pillar/api/timeline.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,373 @@
 | 
			
		||||
import itertools
 | 
			
		||||
import typing
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
from operator import itemgetter
 | 
			
		||||
 | 
			
		||||
import attr
 | 
			
		||||
import bson
 | 
			
		||||
import pymongo
 | 
			
		||||
from flask import Blueprint, current_app, request, url_for
 | 
			
		||||
 | 
			
		||||
import pillar
 | 
			
		||||
from pillar import shortcodes
 | 
			
		||||
from pillar.api.utils import jsonify, pretty_duration, str2id
 | 
			
		||||
 | 
			
		||||
blueprint = Blueprint('timeline', __name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@attr.s(auto_attribs=True)
 | 
			
		||||
class TimelineDO:
 | 
			
		||||
    groups: typing.List['GroupDO'] = []
 | 
			
		||||
    continue_from: typing.Optional[float] = None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@attr.s(auto_attribs=True)
 | 
			
		||||
class GroupDO:
 | 
			
		||||
    label: typing.Optional[str] = None
 | 
			
		||||
    url: typing.Optional[str] = None
 | 
			
		||||
    items: typing.Dict = {}
 | 
			
		||||
    groups: typing.Iterable['GroupDO'] = []
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SearchHelper:
 | 
			
		||||
    def __init__(self, nbr_of_weeks: int, continue_from: typing.Optional[datetime],
 | 
			
		||||
                 project_ids: typing.List[bson.ObjectId], sort_direction: str):
 | 
			
		||||
        self._nbr_of_weeks = nbr_of_weeks
 | 
			
		||||
        self._continue_from = continue_from
 | 
			
		||||
        self._project_ids = project_ids
 | 
			
		||||
        self.sort_direction = sort_direction
 | 
			
		||||
 | 
			
		||||
    def _match(self, continue_from: typing.Optional[datetime]) -> dict:
 | 
			
		||||
        created = {}
 | 
			
		||||
        if continue_from:
 | 
			
		||||
            if self.sort_direction == 'desc':
 | 
			
		||||
                created = {'_created': {'$lt': continue_from}}
 | 
			
		||||
            else:
 | 
			
		||||
                created = {'_created': {'$gt': continue_from}}
 | 
			
		||||
        return {'_deleted': {'$ne': True},
 | 
			
		||||
                'node_type': {'$in': ['asset', 'post']},
 | 
			
		||||
                'project': {'$in': self._project_ids},
 | 
			
		||||
                **created,
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
    def raw_weeks_from_mongo(self) -> pymongo.collection.Collection:
 | 
			
		||||
        direction = pymongo.DESCENDING if self.sort_direction == 'desc' else pymongo.ASCENDING
 | 
			
		||||
        nodes_coll = current_app.db('nodes')
 | 
			
		||||
        return nodes_coll.aggregate([
 | 
			
		||||
            {'$match': self._match(self._continue_from)},
 | 
			
		||||
            {'$lookup': {"from": "projects",
 | 
			
		||||
                         "localField": "project",
 | 
			
		||||
                         "foreignField": "_id",
 | 
			
		||||
                         "as": "project"}},
 | 
			
		||||
            {'$unwind': {'path': "$project"}},
 | 
			
		||||
            {'$lookup': {"from": "users",
 | 
			
		||||
                         "localField": "user",
 | 
			
		||||
                         "foreignField": "_id",
 | 
			
		||||
                         "as": "user"}},
 | 
			
		||||
            {'$unwind': {'path': "$user"}},
 | 
			
		||||
            {'$project': {
 | 
			
		||||
                '_created': 1,
 | 
			
		||||
                'project._id': 1,
 | 
			
		||||
                'project.url': 1,
 | 
			
		||||
                'project.name': 1,
 | 
			
		||||
                'user._id': 1,
 | 
			
		||||
                'user.full_name': 1,
 | 
			
		||||
                'name': 1,
 | 
			
		||||
                'node_type': 1,
 | 
			
		||||
                'picture': 1,
 | 
			
		||||
                'properties': 1,
 | 
			
		||||
                'permissions': 1,
 | 
			
		||||
            }},
 | 
			
		||||
            {'$group': {
 | 
			
		||||
                '_id': {'year': {'$isoWeekYear': '$_created'},
 | 
			
		||||
                        'week': {'$isoWeek': '$_created'}},
 | 
			
		||||
                'nodes': {'$push': '$$ROOT'}
 | 
			
		||||
            }},
 | 
			
		||||
            {'$sort': {'_id.year': direction,
 | 
			
		||||
                       '_id.week': direction}},
 | 
			
		||||
            {'$limit': self._nbr_of_weeks}
 | 
			
		||||
        ])
 | 
			
		||||
 | 
			
		||||
    def has_more(self, continue_from: datetime) -> bool:
 | 
			
		||||
        nodes_coll = current_app.db('nodes')
 | 
			
		||||
        result = nodes_coll.count(self._match(continue_from))
 | 
			
		||||
        return bool(result)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Grouper:
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def label(cls, node):
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def url(cls, node):
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def group_key(cls) -> typing.Callable[[dict], typing.Any]:
 | 
			
		||||
        raise NotImplemented()
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def sort_key(cls) -> typing.Callable[[dict], typing.Any]:
 | 
			
		||||
        raise NotImplemented()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ProjectGrouper(Grouper):
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def label(cls, project: dict):
 | 
			
		||||
        return project['name']
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def url(cls, project: dict):
 | 
			
		||||
        return url_for('projects.view', project_url=project['url'])
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def group_key(cls) -> typing.Callable[[dict], typing.Any]:
 | 
			
		||||
        return itemgetter('project')
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def sort_key(cls) -> typing.Callable[[dict], typing.Any]:
 | 
			
		||||
        return lambda node: node['project']['_id']
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserGrouper(Grouper):
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def label(cls, user):
 | 
			
		||||
        return user['full_name']
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def group_key(cls) -> typing.Callable[[dict], typing.Any]:
 | 
			
		||||
        return itemgetter('user')
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def sort_key(cls) -> typing.Callable[[dict], typing.Any]:
 | 
			
		||||
        return lambda node: node['user']['_id']
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TimeLineBuilder:
 | 
			
		||||
    def __init__(self, search_helper: SearchHelper, grouper: typing.Type[Grouper]):
 | 
			
		||||
        self.search_helper = search_helper
 | 
			
		||||
        self.grouper = grouper
 | 
			
		||||
        self.continue_from = None
 | 
			
		||||
 | 
			
		||||
    def build(self) -> TimelineDO:
 | 
			
		||||
        raw_weeks = self.search_helper.raw_weeks_from_mongo()
 | 
			
		||||
        clean_weeks = (self.create_week_group(week) for week in raw_weeks)
 | 
			
		||||
 | 
			
		||||
        return TimelineDO(
 | 
			
		||||
            groups=list(clean_weeks),
 | 
			
		||||
            continue_from=self.continue_from.timestamp() if self.search_helper.has_more(self.continue_from) else None
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def create_week_group(self, week: dict) -> GroupDO:
 | 
			
		||||
        nodes = week['nodes']
 | 
			
		||||
        nodes.sort(key=itemgetter('_created'), reverse=True)
 | 
			
		||||
        self.update_continue_from(nodes)
 | 
			
		||||
        groups = self.create_groups(nodes)
 | 
			
		||||
 | 
			
		||||
        return GroupDO(
 | 
			
		||||
            label=f'Week {week["_id"]["week"]}, {week["_id"]["year"]}',
 | 
			
		||||
            groups=groups
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def create_groups(self, nodes: typing.List[dict]) -> typing.List[GroupDO]:
 | 
			
		||||
        self.sort_nodes(nodes)  # groupby assumes that the list is sorted
 | 
			
		||||
        nodes_grouped = itertools.groupby(nodes, self.grouper.group_key())
 | 
			
		||||
        groups = (self.clean_group(grouped_by, group) for grouped_by, group in nodes_grouped)
 | 
			
		||||
        groups_sorted = sorted(groups, key=self.group_row_sorter, reverse=True)
 | 
			
		||||
        return groups_sorted
 | 
			
		||||
 | 
			
		||||
    def sort_nodes(self, nodes: typing.List[dict]):
 | 
			
		||||
        nodes.sort(key=itemgetter('node_type'))
 | 
			
		||||
        nodes.sort(key=self.grouper.sort_key())
 | 
			
		||||
 | 
			
		||||
    def update_continue_from(self, sorted_nodes: typing.List[dict]):
 | 
			
		||||
        if self.search_helper.sort_direction == 'desc':
 | 
			
		||||
            first_created = sorted_nodes[-1]['_created']
 | 
			
		||||
            candidate = self.continue_from or first_created
 | 
			
		||||
            self.continue_from = min(candidate, first_created)
 | 
			
		||||
        else:
 | 
			
		||||
            last_created = sorted_nodes[0]['_created']
 | 
			
		||||
            candidate = self.continue_from or last_created
 | 
			
		||||
            self.continue_from = max(candidate, last_created)
 | 
			
		||||
 | 
			
		||||
    def clean_group(self, grouped_by: typing.Any, group: typing.Iterable[dict]) -> GroupDO:
 | 
			
		||||
        items = self.create_items(group)
 | 
			
		||||
        return GroupDO(
 | 
			
		||||
            label=self.grouper.label(grouped_by),
 | 
			
		||||
            url=self.grouper.url(grouped_by),
 | 
			
		||||
            items=items
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def create_items(self, group) -> typing.List[dict]:
 | 
			
		||||
        by_node_type = itertools.groupby(group, key=itemgetter('node_type'))
 | 
			
		||||
        items = {}
 | 
			
		||||
        for node_type, nodes in by_node_type:
 | 
			
		||||
            items[node_type] = [self.node_prettyfy(n) for n in nodes]
 | 
			
		||||
        return items
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def node_prettyfy(cls, node: dict)-> dict:
 | 
			
		||||
        duration_seconds = node['properties'].get('duration_seconds')
 | 
			
		||||
        if duration_seconds is not None:
 | 
			
		||||
            node['properties']['duration'] = pretty_duration(duration_seconds)
 | 
			
		||||
        if node['node_type'] == 'post':
 | 
			
		||||
            html = _get_markdowned_html(node['properties'], 'content')
 | 
			
		||||
            html = shortcodes.render_commented(html, context=node['properties'])
 | 
			
		||||
            node['properties']['pretty_content'] = html
 | 
			
		||||
        return node
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def group_row_sorter(cls, row: GroupDO) -> typing.Tuple[datetime, datetime]:
 | 
			
		||||
        '''
 | 
			
		||||
        If a group contains posts are more interesting and therefor we put them higher in up
 | 
			
		||||
        :param row:
 | 
			
		||||
        :return: tuple with newest post date and newest asset date
 | 
			
		||||
        '''
 | 
			
		||||
        def newest_created(nodes: typing.List[dict]) -> datetime:
 | 
			
		||||
            if nodes:
 | 
			
		||||
                return nodes[0]['_created']
 | 
			
		||||
            return datetime.fromtimestamp(0, tz=bson.tz_util.utc)
 | 
			
		||||
        newest_post_date = newest_created(row.items.get('post'))
 | 
			
		||||
        newest_asset_date = newest_created(row.items.get('asset'))
 | 
			
		||||
        return newest_post_date, newest_asset_date
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _public_project_ids() -> typing.List[bson.ObjectId]:
 | 
			
		||||
    """Returns a list of ObjectIDs of public projects.
 | 
			
		||||
 | 
			
		||||
    Memoized in setup_app().
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    proj_coll = current_app.db('projects')
 | 
			
		||||
    result = proj_coll.find({'is_private': False}, {'_id': 1})
 | 
			
		||||
    return [p['_id'] for p in result]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _get_markdowned_html(document: dict, field_name: str) -> str:
 | 
			
		||||
    cache_field_name = pillar.markdown.cache_field_name(field_name)
 | 
			
		||||
    html = document.get(cache_field_name)
 | 
			
		||||
    if html is None:
 | 
			
		||||
        markdown_src = document.get(field_name) or ''
 | 
			
		||||
        html = pillar.markdown.markdown(markdown_src)
 | 
			
		||||
    return html
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@blueprint.route('/', methods=['GET'])
 | 
			
		||||
def global_timeline():
 | 
			
		||||
    continue_from_str = request.args.get('from')
 | 
			
		||||
    continue_from = parse_continue_from(continue_from_str)
 | 
			
		||||
    nbr_of_weeks_str = request.args.get('weeksToLoad')
 | 
			
		||||
    nbr_of_weeks = parse_nbr_of_weeks(nbr_of_weeks_str)
 | 
			
		||||
    sort_direction = request.args.get('dir', 'desc')
 | 
			
		||||
    return _global_timeline(continue_from, nbr_of_weeks, sort_direction)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@blueprint.route('/p/<string(length=24):pid_path>', methods=['GET'])
 | 
			
		||||
def project_timeline(pid_path: str):
 | 
			
		||||
    continue_from_str = request.args.get('from')
 | 
			
		||||
    continue_from = parse_continue_from(continue_from_str)
 | 
			
		||||
    nbr_of_weeks_str = request.args.get('weeksToLoad')
 | 
			
		||||
    nbr_of_weeks = parse_nbr_of_weeks(nbr_of_weeks_str)
 | 
			
		||||
    sort_direction = request.args.get('dir', 'desc')
 | 
			
		||||
    pid = str2id(pid_path)
 | 
			
		||||
    return _project_timeline(continue_from, nbr_of_weeks, sort_direction, pid)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def parse_continue_from(from_arg) -> typing.Optional[datetime]:
 | 
			
		||||
    try:
 | 
			
		||||
        from_float = float(from_arg)
 | 
			
		||||
    except (TypeError, ValueError):
 | 
			
		||||
        return None
 | 
			
		||||
    return datetime.fromtimestamp(from_float, tz=bson.tz_util.utc)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def parse_nbr_of_weeks(weeks_to_load: str) -> int:
 | 
			
		||||
    try:
 | 
			
		||||
        return int(weeks_to_load)
 | 
			
		||||
    except (TypeError, ValueError):
 | 
			
		||||
        return 3
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _global_timeline(continue_from: typing.Optional[datetime], nbr_of_weeks: int, sort_direction: str):
 | 
			
		||||
    """Returns an aggregated view of what has happened on the site
 | 
			
		||||
    Memoized in setup_app().
 | 
			
		||||
 | 
			
		||||
    :param continue_from: Python utc timestamp where to begin aggregation
 | 
			
		||||
 | 
			
		||||
    :param nbr_of_weeks: Number of weeks to return
 | 
			
		||||
 | 
			
		||||
    Example output:
 | 
			
		||||
    {
 | 
			
		||||
    groups: [{
 | 
			
		||||
        label: 'Week 32',
 | 
			
		||||
        groups: [{
 | 
			
		||||
            label: 'Spring',
 | 
			
		||||
            url: '/p/spring',
 | 
			
		||||
            items:{
 | 
			
		||||
                post: [blogPostDoc, blogPostDoc],
 | 
			
		||||
                asset: [assetDoc, assetDoc]
 | 
			
		||||
            },
 | 
			
		||||
            groups: ...
 | 
			
		||||
            }]
 | 
			
		||||
        }],
 | 
			
		||||
        continue_from: 123456.2 // python timestamp
 | 
			
		||||
    }
 | 
			
		||||
    """
 | 
			
		||||
    builder = TimeLineBuilder(
 | 
			
		||||
        SearchHelper(nbr_of_weeks, continue_from, _public_project_ids(), sort_direction),
 | 
			
		||||
        ProjectGrouper
 | 
			
		||||
    )
 | 
			
		||||
    return jsonify_timeline(builder.build())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def jsonify_timeline(timeline: TimelineDO):
 | 
			
		||||
    return jsonify(
 | 
			
		||||
        attr.asdict(timeline,
 | 
			
		||||
                    recurse=True,
 | 
			
		||||
                    filter=lambda att, value: value is not None)
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _project_timeline(continue_from: typing.Optional[datetime], nbr_of_weeks: int, sort_direction, pid: bson.ObjectId):
 | 
			
		||||
    """Returns an aggregated view of what has happened on the site
 | 
			
		||||
    Memoized in setup_app().
 | 
			
		||||
 | 
			
		||||
    :param continue_from: Python utc timestamp where to begin aggregation
 | 
			
		||||
 | 
			
		||||
    :param nbr_of_weeks: Number of weeks to return
 | 
			
		||||
 | 
			
		||||
    Example output:
 | 
			
		||||
    {
 | 
			
		||||
    groups: [{
 | 
			
		||||
        label: 'Week 32',
 | 
			
		||||
        groups: [{
 | 
			
		||||
            label: 'Tobias Johansson',
 | 
			
		||||
            items:{
 | 
			
		||||
                post: [blogPostDoc, blogPostDoc],
 | 
			
		||||
                asset: [assetDoc, assetDoc]
 | 
			
		||||
            },
 | 
			
		||||
            groups: ...
 | 
			
		||||
            }]
 | 
			
		||||
        }],
 | 
			
		||||
        continue_from: 123456.2 // python timestamp
 | 
			
		||||
    }
 | 
			
		||||
    """
 | 
			
		||||
    builder = TimeLineBuilder(
 | 
			
		||||
        SearchHelper(nbr_of_weeks, continue_from, [pid], sort_direction),
 | 
			
		||||
        UserGrouper
 | 
			
		||||
    )
 | 
			
		||||
    return jsonify_timeline(builder.build())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def setup_app(app, url_prefix):
 | 
			
		||||
    global _public_project_ids
 | 
			
		||||
    global _global_timeline
 | 
			
		||||
    global _project_timeline
 | 
			
		||||
 | 
			
		||||
    app.register_api_blueprint(blueprint, url_prefix=url_prefix)
 | 
			
		||||
    cached = app.cache.cached(timeout=3600)
 | 
			
		||||
    _public_project_ids = cached(_public_project_ids)
 | 
			
		||||
    memoize = app.cache.memoize(timeout=60)
 | 
			
		||||
    _global_timeline = memoize(_global_timeline)
 | 
			
		||||
    _project_timeline = memoize(_project_timeline)
 | 
			
		||||
@@ -57,6 +57,18 @@ def remove_private_keys(document):
 | 
			
		||||
    return doc_copy
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def pretty_duration(seconds):
 | 
			
		||||
    if seconds is None:
 | 
			
		||||
        return ''
 | 
			
		||||
    seconds = round(seconds)
 | 
			
		||||
    hours, seconds = divmod(seconds, 3600)
 | 
			
		||||
    minutes, seconds = divmod(seconds, 60)
 | 
			
		||||
    if hours > 0:
 | 
			
		||||
        return f'{hours:02}:{minutes:02}:{seconds:02}'
 | 
			
		||||
    else:
 | 
			
		||||
        return f'{minutes:02}:{seconds:02}'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PillarJSONEncoder(json.JSONEncoder):
 | 
			
		||||
    """JSON encoder with support for Pillar resources."""
 | 
			
		||||
 | 
			
		||||
@@ -64,6 +76,9 @@ class PillarJSONEncoder(json.JSONEncoder):
 | 
			
		||||
        if isinstance(obj, datetime.datetime):
 | 
			
		||||
            return obj.strftime(RFC1123_DATE_FORMAT)
 | 
			
		||||
 | 
			
		||||
        if isinstance(obj, datetime.timedelta):
 | 
			
		||||
            return pretty_duration(obj.total_seconds())
 | 
			
		||||
 | 
			
		||||
        if isinstance(obj, bson.ObjectId):
 | 
			
		||||
            return str(obj)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -189,7 +189,7 @@ def validate_this_token(token, oauth_subclient=None):
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    g.current_user = UserClass.construct(token, db_user)
 | 
			
		||||
    user_authenticated.send(None)
 | 
			
		||||
    user_authenticated.send(g.current_user)
 | 
			
		||||
 | 
			
		||||
    return db_user
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -12,7 +12,10 @@ from werkzeug.local import LocalProxy
 | 
			
		||||
 | 
			
		||||
from pillar import current_app
 | 
			
		||||
 | 
			
		||||
# The sender is the user that was just authenticated.
 | 
			
		||||
user_authenticated = blinker.Signal('Sent whenever a user was authenticated')
 | 
			
		||||
user_logged_in = blinker.Signal('Sent whenever a user logged in on the web')
 | 
			
		||||
 | 
			
		||||
log = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
# Mapping from user role to capabilities obtained by users with that role.
 | 
			
		||||
@@ -225,7 +228,8 @@ 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)
 | 
			
		||||
    user_authenticated.send(user)
 | 
			
		||||
    user_logged_in.send(user)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def logout_user():
 | 
			
		||||
 
 | 
			
		||||
@@ -7,7 +7,7 @@ from urllib.parse import urljoin
 | 
			
		||||
import bson
 | 
			
		||||
import requests
 | 
			
		||||
 | 
			
		||||
from pillar import current_app
 | 
			
		||||
from pillar import current_app, auth
 | 
			
		||||
from pillar.api.utils import utcnow
 | 
			
		||||
 | 
			
		||||
SyncUser = collections.namedtuple('SyncUser', 'user_id token bid_user_id')
 | 
			
		||||
@@ -23,6 +23,41 @@ class StopRefreshing(Exception):
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def find_user_to_sync(user_id: bson.ObjectId) -> typing.Optional[SyncUser]:
 | 
			
		||||
    """Return user information for syncing badges for a specific user.
 | 
			
		||||
 | 
			
		||||
    Returns None if the user cannot be synced (no 'badge' scope on a token,
 | 
			
		||||
    or no Blender ID user_id known).
 | 
			
		||||
    """
 | 
			
		||||
    my_log = log.getChild('refresh_single_user')
 | 
			
		||||
 | 
			
		||||
    now = utcnow()
 | 
			
		||||
    tokens_coll = current_app.db('tokens')
 | 
			
		||||
    users_coll = current_app.db('users')
 | 
			
		||||
 | 
			
		||||
    token_info = tokens_coll.find_one({
 | 
			
		||||
        'user': user_id,
 | 
			
		||||
        'token': {'$exists': True},
 | 
			
		||||
        'oauth_scopes': 'badge',
 | 
			
		||||
        'expire_time': {'$gt': now},
 | 
			
		||||
    })
 | 
			
		||||
    if not token_info:
 | 
			
		||||
        my_log.debug('No token with scope "badge" for user %s', user_id)
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    user_info = users_coll.find_one({'_id': user_id})
 | 
			
		||||
    # TODO(Sybren): do this filtering in the MongoDB query:
 | 
			
		||||
    bid_user_ids = [auth_info.get('user_id')
 | 
			
		||||
                    for auth_info in user_info.get('auth', [])
 | 
			
		||||
                    if auth_info.get('provider', '') == 'blender-id' and auth_info.get('user_id')]
 | 
			
		||||
    if not bid_user_ids:
 | 
			
		||||
        my_log.debug('No Blender ID user_id for user %s', user_id)
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    bid_user_id = bid_user_ids[0]
 | 
			
		||||
    return SyncUser(user_id=user_id, token=token_info['token'], bid_user_id=bid_user_id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def find_users_to_sync() -> typing.Iterable[SyncUser]:
 | 
			
		||||
    """Return user information of syncable users with badges."""
 | 
			
		||||
 | 
			
		||||
@@ -34,6 +69,7 @@ def find_users_to_sync() -> typing.Iterable[SyncUser]:
 | 
			
		||||
            'token': {'$exists': True},
 | 
			
		||||
            'oauth_scopes': 'badge',
 | 
			
		||||
            'expire_time': {'$gt': now},
 | 
			
		||||
            # TODO(Sybren): save real token expiry time but keep checking tokens hourly when they are used!
 | 
			
		||||
        }},
 | 
			
		||||
        {'$lookup': {
 | 
			
		||||
            'from': 'users',
 | 
			
		||||
@@ -62,7 +98,6 @@ def find_users_to_sync() -> typing.Iterable[SyncUser]:
 | 
			
		||||
            'token': True,
 | 
			
		||||
            'user._id': True,
 | 
			
		||||
            'user.auth.user_id': True,
 | 
			
		||||
            'user.badges.expires': True,
 | 
			
		||||
        }},
 | 
			
		||||
    ])
 | 
			
		||||
 | 
			
		||||
@@ -101,6 +136,7 @@ def fetch_badge_html(session: requests.Session, user: SyncUser, size: str) \
 | 
			
		||||
        my_log.debug('No badges for user %s', user.user_id)
 | 
			
		||||
        return ''
 | 
			
		||||
    if resp.status_code == 403:
 | 
			
		||||
        # TODO(Sybren): this indicates the token is invalid, so we could just as well delete it.
 | 
			
		||||
        my_log.warning('Tried fetching %s for user %s but received a 403: %s',
 | 
			
		||||
                       url, user.user_id, resp.text)
 | 
			
		||||
        return ''
 | 
			
		||||
@@ -133,18 +169,14 @@ def refresh_all_badges(only_user_id: typing.Optional[bson.ObjectId] = None, *,
 | 
			
		||||
        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')
 | 
			
		||||
    my_log = log.getChild('refresh_all_badges')
 | 
			
		||||
 | 
			
		||||
    # 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')
 | 
			
		||||
 | 
			
		||||
    session = _get_requests_session()
 | 
			
		||||
    deadline = utcnow() + timelimit
 | 
			
		||||
 | 
			
		||||
    num_updates = 0
 | 
			
		||||
@@ -164,20 +196,71 @@ def refresh_all_badges(only_user_id: typing.Optional[bson.ObjectId] = None, *,
 | 
			
		||||
                         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)
 | 
			
		||||
        update_badges(user_info, badge_html, badge_expiry, dry_run=dry_run)
 | 
			
		||||
    my_log.info('Updated badges of %d users%s', num_updates, ' (dry-run)' if dry_run else '')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _get_requests_session() -> requests.Session:
 | 
			
		||||
    from requests.adapters import HTTPAdapter
 | 
			
		||||
    session = requests.Session()
 | 
			
		||||
    session.mount('https://', HTTPAdapter(max_retries=5))
 | 
			
		||||
    return session
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def refresh_single_user(user_id: bson.ObjectId):
 | 
			
		||||
    """Refresh badges for a single user."""
 | 
			
		||||
    my_log = log.getChild('refresh_single_user')
 | 
			
		||||
 | 
			
		||||
    badge_expiry = badge_expiry_config()
 | 
			
		||||
    if not badge_expiry:
 | 
			
		||||
        my_log.warning('Skipping badge fetching, BLENDER_ID_BADGE_EXPIRY not configured')
 | 
			
		||||
 | 
			
		||||
    my_log.debug('Fetching badges for user %s', user_id)
 | 
			
		||||
    session = _get_requests_session()
 | 
			
		||||
    user_info = find_user_to_sync(user_id)
 | 
			
		||||
    if not user_info:
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    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)
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    update_badges(user_info, badge_html, badge_expiry, dry_run=False)
 | 
			
		||||
    my_log.info('Updated badges of user %s', user_id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def update_badges(user_info: SyncUser, badge_html: str, badge_expiry: datetime.timedelta,
 | 
			
		||||
                  *, dry_run: bool):
 | 
			
		||||
    my_log = log.getChild('update_badges')
 | 
			
		||||
    users_coll = current_app.db('users')
 | 
			
		||||
 | 
			
		||||
    update = {'badges': {
 | 
			
		||||
        'html': badge_html,
 | 
			
		||||
        'expires': utcnow() + badge_expiry,
 | 
			
		||||
    }}
 | 
			
		||||
    my_log.info('Updating badges HTML for Blender ID %s, user %s',
 | 
			
		||||
                user_info.bid_user_id, user_info.user_id)
 | 
			
		||||
 | 
			
		||||
    if dry_run:
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    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)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def badge_expiry_config() -> datetime.timedelta:
 | 
			
		||||
    return current_app.config.get('BLENDER_ID_BADGE_EXPIRY')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@auth.user_logged_in.connect
 | 
			
		||||
def sync_badge_upon_login(sender: auth.UserClass, **kwargs):
 | 
			
		||||
    """Auto-sync badges when a user logs in."""
 | 
			
		||||
 | 
			
		||||
    log.info('Refreshing badge of %s because they logged in', sender.user_id)
 | 
			
		||||
    refresh_single_user(sender.user_id)
 | 
			
		||||
 
 | 
			
		||||
@@ -1,38 +0,0 @@
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
from algoliasearch.helpers import AlgoliaException
 | 
			
		||||
 | 
			
		||||
log = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def push_updated_user(user_to_index: dict):
 | 
			
		||||
    """Push an update to the Algolia index when a user item is updated"""
 | 
			
		||||
 | 
			
		||||
    from pillar.api.utils.algolia import index_user_save
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        index_user_save(user_to_index)
 | 
			
		||||
    except AlgoliaException as ex:
 | 
			
		||||
        log.warning(
 | 
			
		||||
            'Unable to push user info to Algolia for user "%s", id=%s; %s',  # noqa
 | 
			
		||||
            user_to_index.get('username'),
 | 
			
		||||
            user_to_index.get('objectID'), ex)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def index_node_save(node_to_index: dict):
 | 
			
		||||
    from pillar.api.utils import algolia
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        algolia.index_node_save(node_to_index)
 | 
			
		||||
    except AlgoliaException as ex:
 | 
			
		||||
        log.warning(
 | 
			
		||||
            'Unable to push node info to Algolia for node %s; %s', node_to_index, ex)  # noqa
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def index_node_delete(delete_id: str):
 | 
			
		||||
 | 
			
		||||
    from pillar.api.utils import algolia
 | 
			
		||||
    try:
 | 
			
		||||
        algolia.index_node_delete(delete_id)
 | 
			
		||||
    except AlgoliaException as ex:
 | 
			
		||||
        log.warning('Unable to delete node info to Algolia for node %s; %s', delete_id, ex)  # noqa
 | 
			
		||||
@@ -1,4 +1,6 @@
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
import bleach
 | 
			
		||||
from bson import ObjectId
 | 
			
		||||
 | 
			
		||||
from pillar import current_app
 | 
			
		||||
@@ -10,7 +12,7 @@ from pillar.api.search import algolia_indexing
 | 
			
		||||
log = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
INDEX_ALLOWED_NODE_TYPES = {'asset', 'texture', 'group', 'hdri'}
 | 
			
		||||
INDEX_ALLOWED_NODE_TYPES = {'asset', 'texture', 'group', 'hdri', 'post'}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
SEARCH_BACKENDS = {
 | 
			
		||||
@@ -28,34 +30,6 @@ def _get_node_from_id(node_id: str):
 | 
			
		||||
    return node
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _handle_picture(node: dict, to_index: dict):
 | 
			
		||||
    """Add picture URL in-place to the to-be-indexed node."""
 | 
			
		||||
 | 
			
		||||
    picture_id = node.get('picture')
 | 
			
		||||
    if not picture_id:
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    files_collection = current_app.data.driver.db['files']
 | 
			
		||||
    lookup = {'_id': ObjectId(picture_id)}
 | 
			
		||||
    picture = files_collection.find_one(lookup)
 | 
			
		||||
 | 
			
		||||
    for item in picture.get('variations', []):
 | 
			
		||||
        if item['size'] != 't':
 | 
			
		||||
            continue
 | 
			
		||||
 | 
			
		||||
        # Not all files have a project...
 | 
			
		||||
        pid = picture.get('project')
 | 
			
		||||
        if pid:
 | 
			
		||||
            link = generate_link(picture['backend'],
 | 
			
		||||
                                 item['file_path'],
 | 
			
		||||
                                 str(pid),
 | 
			
		||||
                                 is_public=True)
 | 
			
		||||
        else:
 | 
			
		||||
            link = item['link']
 | 
			
		||||
        to_index['picture'] = link
 | 
			
		||||
        break
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def prepare_node_data(node_id: str, node: dict=None) -> dict:
 | 
			
		||||
    """Given a node id or a node document, return an indexable version of it.
 | 
			
		||||
 | 
			
		||||
@@ -86,25 +60,30 @@ def prepare_node_data(node_id: str, node: dict=None) -> dict:
 | 
			
		||||
    users_collection = current_app.data.driver.db['users']
 | 
			
		||||
    user = users_collection.find_one({'_id': ObjectId(node['user'])})
 | 
			
		||||
 | 
			
		||||
    clean_description = bleach.clean(node.get('_description_html') or '', strip=True)
 | 
			
		||||
    if not clean_description and node['node_type'] == 'post':
 | 
			
		||||
        clean_description = bleach.clean(node['properties'].get('_content_html') or '', strip=True)
 | 
			
		||||
 | 
			
		||||
    to_index = {
 | 
			
		||||
        'objectID': node['_id'],
 | 
			
		||||
        'name': node['name'],
 | 
			
		||||
        'project': {
 | 
			
		||||
            '_id': project['_id'],
 | 
			
		||||
            'name': project['name']
 | 
			
		||||
            'name': project['name'],
 | 
			
		||||
            'url': project['url'],
 | 
			
		||||
        },
 | 
			
		||||
        'created': node['_created'],
 | 
			
		||||
        'updated': node['_updated'],
 | 
			
		||||
        'node_type': node['node_type'],
 | 
			
		||||
        'picture': node.get('picture') or '',
 | 
			
		||||
        'user': {
 | 
			
		||||
            '_id': user['_id'],
 | 
			
		||||
            'full_name': user['full_name']
 | 
			
		||||
        },
 | 
			
		||||
        'description': node.get('description'),
 | 
			
		||||
        'description': clean_description or None,
 | 
			
		||||
        'is_free': False
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _handle_picture(node, to_index)
 | 
			
		||||
 | 
			
		||||
    # If the node has world permissions, compute the Free permission
 | 
			
		||||
    if 'world' in node.get('permissions', {}):
 | 
			
		||||
        if 'GET' in node['permissions']['world']:
 | 
			
		||||
 
 | 
			
		||||
@@ -559,50 +559,6 @@ def replace_pillar_node_type_schemas(project_url=None, all_projects=False, missi
 | 
			
		||||
             projects_changed, projects_seen)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@manager_maintenance.command
 | 
			
		||||
def remarkdown_comments():
 | 
			
		||||
    """Retranslates all Markdown to HTML for all comment nodes.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    from pillar.api.nodes import convert_markdown
 | 
			
		||||
 | 
			
		||||
    nodes_collection = current_app.db()['nodes']
 | 
			
		||||
    comments = nodes_collection.find({'node_type': 'comment'},
 | 
			
		||||
                                     projection={'properties.content': 1,
 | 
			
		||||
                                                 'node_type': 1})
 | 
			
		||||
 | 
			
		||||
    updated = identical = skipped = errors = 0
 | 
			
		||||
    for node in comments:
 | 
			
		||||
        convert_markdown(node)
 | 
			
		||||
        node_id = node['_id']
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            content_html = node['properties']['content_html']
 | 
			
		||||
        except KeyError:
 | 
			
		||||
            log.warning('Node %s has no content_html', node_id)
 | 
			
		||||
            skipped += 1
 | 
			
		||||
            continue
 | 
			
		||||
 | 
			
		||||
        result = nodes_collection.update_one(
 | 
			
		||||
            {'_id': node_id},
 | 
			
		||||
            {'$set': {'properties.content_html': content_html}}
 | 
			
		||||
        )
 | 
			
		||||
        if result.matched_count != 1:
 | 
			
		||||
            log.error('Unable to update node %s', node_id)
 | 
			
		||||
            errors += 1
 | 
			
		||||
            continue
 | 
			
		||||
 | 
			
		||||
        if result.modified_count:
 | 
			
		||||
            updated += 1
 | 
			
		||||
        else:
 | 
			
		||||
            identical += 1
 | 
			
		||||
 | 
			
		||||
    log.info('updated  : %i', updated)
 | 
			
		||||
    log.info('identical: %i', identical)
 | 
			
		||||
    log.info('skipped  : %i', skipped)
 | 
			
		||||
    log.info('errors   : %i', errors)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@manager_maintenance.option('-p', '--project', dest='proj_url', nargs='?',
 | 
			
		||||
                            help='Project URL')
 | 
			
		||||
@manager_maintenance.option('-a', '--all', dest='all_projects', action='store_true', default=False,
 | 
			
		||||
@@ -1066,3 +1022,156 @@ def delete_orphan_files():
 | 
			
		||||
        log.warning('Soft-deletion modified %d of %d files', res.modified_count, file_count)
 | 
			
		||||
 | 
			
		||||
    log.info('%d files have been soft-deleted', res.modified_count)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@manager_maintenance.command
 | 
			
		||||
def find_video_files_without_duration():
 | 
			
		||||
    """Finds video files without any duration
 | 
			
		||||
 | 
			
		||||
    This is a heavy operation. Use with care.
 | 
			
		||||
    """
 | 
			
		||||
    from pathlib import Path
 | 
			
		||||
 | 
			
		||||
    output_fpath = Path(current_app.config['STORAGE_DIR']) / 'video_files_without_duration.txt'
 | 
			
		||||
    if output_fpath.exists():
 | 
			
		||||
        log.error('Output filename %s already exists, remove it first.', output_fpath)
 | 
			
		||||
        return 1
 | 
			
		||||
 | 
			
		||||
    start_timestamp = datetime.datetime.now()
 | 
			
		||||
    files_coll = current_app.db('files')
 | 
			
		||||
    starts_with_video = re.compile("^video", re.IGNORECASE)
 | 
			
		||||
    aggr = files_coll.aggregate([
 | 
			
		||||
        {'$match': {'content_type': starts_with_video,
 | 
			
		||||
                    '_deleted': {'$ne': True}}},
 | 
			
		||||
        {'$unwind': '$variations'},
 | 
			
		||||
        {'$match': {
 | 
			
		||||
            'variations.duration': {'$not': {'$gt': 0}}
 | 
			
		||||
        }},
 | 
			
		||||
        {'$project': {'_id': 1}}
 | 
			
		||||
    ])
 | 
			
		||||
 | 
			
		||||
    file_ids = [str(f['_id']) for f in aggr]
 | 
			
		||||
    nbr_files = len(file_ids)
 | 
			
		||||
    log.info('Total nbr video files without duration: %d', nbr_files)
 | 
			
		||||
 | 
			
		||||
    end_timestamp = datetime.datetime.now()
 | 
			
		||||
    duration = end_timestamp - start_timestamp
 | 
			
		||||
    log.info('Finding files took %s', duration)
 | 
			
		||||
 | 
			
		||||
    log.info('Writing Object IDs to %s', output_fpath)
 | 
			
		||||
    with output_fpath.open('w', encoding='ascii') as outfile:
 | 
			
		||||
        outfile.write('\n'.join(sorted(file_ids)))
 | 
			
		||||
 | 
			
		||||
@manager_maintenance.command
 | 
			
		||||
def find_video_nodes_without_duration():
 | 
			
		||||
    """Finds video nodes without any duration
 | 
			
		||||
 | 
			
		||||
    This is a heavy operation. Use with care.
 | 
			
		||||
    """
 | 
			
		||||
    from pathlib import Path
 | 
			
		||||
 | 
			
		||||
    output_fpath = Path(current_app.config['STORAGE_DIR']) / 'video_nodes_without_duration.txt'
 | 
			
		||||
    if output_fpath.exists():
 | 
			
		||||
        log.error('Output filename %s already exists, remove it first.', output_fpath)
 | 
			
		||||
        return 1
 | 
			
		||||
 | 
			
		||||
    start_timestamp = datetime.datetime.now()
 | 
			
		||||
    nodes_coll = current_app.db('nodes')
 | 
			
		||||
 | 
			
		||||
    aggr = nodes_coll.aggregate([
 | 
			
		||||
        {'$match': {'node_type': 'asset',
 | 
			
		||||
                    'properties.content_type': 'video',
 | 
			
		||||
                    '_deleted': {'$ne': True},
 | 
			
		||||
                    'properties.duration_seconds': {'$not': {'$gt': 0}}}},
 | 
			
		||||
        {'$project': {'_id': 1}}
 | 
			
		||||
    ])
 | 
			
		||||
 | 
			
		||||
    file_ids = [str(f['_id']) for f in aggr]
 | 
			
		||||
    nbr_files = len(file_ids)
 | 
			
		||||
    log.info('Total nbr video nodes without duration: %d', nbr_files)
 | 
			
		||||
 | 
			
		||||
    end_timestamp = datetime.datetime.now()
 | 
			
		||||
    duration = end_timestamp - start_timestamp
 | 
			
		||||
    log.info('Finding nodes took %s', duration)
 | 
			
		||||
 | 
			
		||||
    log.info('Writing Object IDs to %s', output_fpath)
 | 
			
		||||
    with output_fpath.open('w', encoding='ascii') as outfile:
 | 
			
		||||
        outfile.write('\n'.join(sorted(file_ids)))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@manager_maintenance.option('-n', '--nodes', dest='nodes_to_update', nargs='*',
 | 
			
		||||
                            help='List of nodes to update')
 | 
			
		||||
@manager_maintenance.option('-a', '--all', dest='all_nodes', action='store_true', default=False,
 | 
			
		||||
                            help='Update on all video nodes.')
 | 
			
		||||
@manager_maintenance.option('-g', '--go', dest='go', action='store_true', default=False,
 | 
			
		||||
                            help='Actually perform the changes (otherwise just show as dry-run).')
 | 
			
		||||
def reconcile_node_video_duration(nodes_to_update=None, all_nodes=False, go=False):
 | 
			
		||||
    """Copy video duration from file.variations.duration to node.properties.duraion_seconds
 | 
			
		||||
 | 
			
		||||
    This is a heavy operation. Use with care.
 | 
			
		||||
    """
 | 
			
		||||
    from pillar.api.utils import random_etag, utcnow
 | 
			
		||||
 | 
			
		||||
    if bool(nodes_to_update) == all_nodes:
 | 
			
		||||
        log.error('Use either --nodes or --all.')
 | 
			
		||||
        return 1
 | 
			
		||||
 | 
			
		||||
    start_timestamp = datetime.datetime.now()
 | 
			
		||||
 | 
			
		||||
    nodes_coll = current_app.db('nodes')
 | 
			
		||||
    node_subset = []
 | 
			
		||||
    if nodes_to_update:
 | 
			
		||||
        node_subset = [{'$match': {'_id': {'$in': [ObjectId(nid) for nid in nodes_to_update]}}}]
 | 
			
		||||
    files = nodes_coll.aggregate(
 | 
			
		||||
        [
 | 
			
		||||
            *node_subset,
 | 
			
		||||
            {'$match': {
 | 
			
		||||
                'node_type': 'asset',
 | 
			
		||||
                'properties.content_type': 'video',
 | 
			
		||||
                '_deleted': {'$ne': True}}
 | 
			
		||||
            },
 | 
			
		||||
            {'$lookup': {
 | 
			
		||||
                'from': 'files',
 | 
			
		||||
                'localField': 'properties.file',
 | 
			
		||||
                'foreignField': '_id',
 | 
			
		||||
                'as': '_files',
 | 
			
		||||
            }},
 | 
			
		||||
            {'$unwind': '$_files'},
 | 
			
		||||
            {'$unwind': '$_files.variations'},
 | 
			
		||||
            {'$match': {'_files.variations.duration': {'$gt': 0}}},
 | 
			
		||||
            {'$addFields': {
 | 
			
		||||
                'need_update': {'$ne': ['$_files.variations.duration', '$properties.duration_seconds']}
 | 
			
		||||
            }},
 | 
			
		||||
            {'$match': {'need_update': True}},
 | 
			
		||||
            {'$project': {
 | 
			
		||||
                '_id': 1,
 | 
			
		||||
                'duration': '$_files.variations.duration',
 | 
			
		||||
            }}]
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    if not go:
 | 
			
		||||
        log.info('Would try to update %d nodes', len(list(files)))
 | 
			
		||||
        return 0
 | 
			
		||||
 | 
			
		||||
    modified_count = 0
 | 
			
		||||
    for f in files:
 | 
			
		||||
        log.debug('Updating node %s with duration %d', f['_id'], f['duration'])
 | 
			
		||||
        new_etag = random_etag()
 | 
			
		||||
        now = utcnow()
 | 
			
		||||
        resp = nodes_coll.update_one(
 | 
			
		||||
            {'_id': f['_id']},
 | 
			
		||||
            {'$set': {
 | 
			
		||||
                'properties.duration_seconds': f['duration'],
 | 
			
		||||
                '_etag': new_etag,
 | 
			
		||||
                '_updated': now,
 | 
			
		||||
            }}
 | 
			
		||||
        )
 | 
			
		||||
        if resp.modified_count == 0:
 | 
			
		||||
            log.debug('Node %s was already up to date', f['_id'])
 | 
			
		||||
        modified_count += resp.modified_count
 | 
			
		||||
 | 
			
		||||
    log.info('Updated %d nodes', modified_count)
 | 
			
		||||
    end_timestamp = datetime.datetime.now()
 | 
			
		||||
    duration = end_timestamp - start_timestamp
 | 
			
		||||
    log.info('Operation took %s', duration)
 | 
			
		||||
    return 0
 | 
			
		||||
 
 | 
			
		||||
@@ -208,8 +208,8 @@ CELERY_BEAT_SCHEDULE = {
 | 
			
		||||
    },
 | 
			
		||||
    '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'
 | 
			
		||||
        'schedule': 10 * 60,  # every N seconds
 | 
			
		||||
        'args': (9 * 60, ),  # time limit in seconds, keep shorter than 'schedule'
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -270,3 +270,14 @@ STATIC_FILE_HASH = ''
 | 
			
		||||
# all API endpoints do not need it. On the views that require it, we use the
 | 
			
		||||
# current_app.csrf.protect() method.
 | 
			
		||||
WTF_CSRF_CHECK_DEFAULT = False
 | 
			
		||||
 | 
			
		||||
# Flask Debug Toolbar. Enable it by overriding DEBUG_TB_ENABLED in config_local.py.
 | 
			
		||||
DEBUG_TB_ENABLED = False
 | 
			
		||||
DEBUG_TB_PANELS = [
 | 
			
		||||
    'flask_debugtoolbar.panels.versions.VersionDebugPanel',
 | 
			
		||||
    'flask_debugtoolbar.panels.headers.HeaderDebugPanel',
 | 
			
		||||
    'flask_debugtoolbar.panels.request_vars.RequestVarsDebugPanel',
 | 
			
		||||
    'flask_debugtoolbar.panels.config_vars.ConfigVarsDebugPanel',
 | 
			
		||||
    'flask_debugtoolbar.panels.template.TemplateDebugPanel',
 | 
			
		||||
    'flask_debugtoolbar.panels.logger.LoggingPanel',
 | 
			
		||||
    'flask_debugtoolbar.panels.route_list.RouteListDebugPanel']
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
"""Our custom Jinja filters and other template stuff."""
 | 
			
		||||
 | 
			
		||||
import functools
 | 
			
		||||
import json
 | 
			
		||||
import logging
 | 
			
		||||
import typing
 | 
			
		||||
import urllib.parse
 | 
			
		||||
@@ -13,6 +14,7 @@ import werkzeug.exceptions as wz_exceptions
 | 
			
		||||
import pillarsdk
 | 
			
		||||
 | 
			
		||||
import pillar.api.utils
 | 
			
		||||
from pillar.api.utils import pretty_duration
 | 
			
		||||
from pillar.web.utils import pretty_date
 | 
			
		||||
from pillar.web.nodes.routes import url_for_node
 | 
			
		||||
import pillar.markdown
 | 
			
		||||
@@ -28,6 +30,10 @@ def format_pretty_date_time(d):
 | 
			
		||||
    return pretty_date(d, detail=True)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def format_pretty_duration(s):
 | 
			
		||||
    return pretty_duration(s)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def format_undertitle(s):
 | 
			
		||||
    """Underscore-replacing title filter.
 | 
			
		||||
 | 
			
		||||
@@ -200,9 +206,16 @@ def do_yesno(value, arg=None):
 | 
			
		||||
    return no
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def do_json(some_object) -> str:
 | 
			
		||||
    if isinstance(some_object, pillarsdk.Resource):
 | 
			
		||||
        some_object = some_object.to_dict()
 | 
			
		||||
    return json.dumps(some_object)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def setup_jinja_env(jinja_env, app_config: dict):
 | 
			
		||||
    jinja_env.filters['pretty_date'] = format_pretty_date
 | 
			
		||||
    jinja_env.filters['pretty_date_time'] = format_pretty_date_time
 | 
			
		||||
    jinja_env.filters['pretty_duration'] = format_pretty_duration
 | 
			
		||||
    jinja_env.filters['undertitle'] = format_undertitle
 | 
			
		||||
    jinja_env.filters['hide_none'] = do_hide_none
 | 
			
		||||
    jinja_env.filters['pluralize'] = do_pluralize
 | 
			
		||||
@@ -212,6 +225,7 @@ def setup_jinja_env(jinja_env, app_config: dict):
 | 
			
		||||
    jinja_env.filters['yesno'] = do_yesno
 | 
			
		||||
    jinja_env.filters['repr'] = repr
 | 
			
		||||
    jinja_env.filters['urljoin'] = functools.partial(urllib.parse.urljoin, allow_fragments=True)
 | 
			
		||||
    jinja_env.filters['json'] = do_json
 | 
			
		||||
    jinja_env.globals['url_for_node'] = do_url_for_node
 | 
			
		||||
    jinja_env.globals['abs_url'] = functools.partial(flask.url_for,
 | 
			
		||||
                                                     _external=True,
 | 
			
		||||
 
 | 
			
		||||
@@ -145,12 +145,21 @@ def comments_for_node(node_id):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def render_comments_for_node(node_id: str, *, can_post_comments: bool):
 | 
			
		||||
    """Render the list of comments for a node."""
 | 
			
		||||
    """Render the list of comments for a node.
 | 
			
		||||
 | 
			
		||||
    Comments are first sorted by confidence, see:
 | 
			
		||||
    https://redditblog.com/2009/10/15/reddits-new-comment-sorting-system/
 | 
			
		||||
    and then by creation date.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    # TODO(fsiddi) Implement confidence calculation on node rating in Pillar core.
 | 
			
		||||
    # Currently this feature is being developed in the Dillo extension.
 | 
			
		||||
    api = system_util.pillar_api()
 | 
			
		||||
 | 
			
		||||
    # Query for all children, i.e. comments on the node.
 | 
			
		||||
    comments = Node.all({
 | 
			
		||||
        'where': {'node_type': 'comment', 'parent': node_id},
 | 
			
		||||
        'sort': [('properties.confidence', -1), ('_created', -1)],
 | 
			
		||||
    }, api=api)
 | 
			
		||||
 | 
			
		||||
    def enrich(some_comment):
 | 
			
		||||
@@ -171,6 +180,7 @@ def render_comments_for_node(node_id: str, *, can_post_comments: bool):
 | 
			
		||||
        # Query for all grandchildren, i.e. replies to comments on the node.
 | 
			
		||||
        comment['_replies'] = Node.all({
 | 
			
		||||
            'where': {'node_type': 'comment', 'parent': comment['_id']},
 | 
			
		||||
            'sort': [('properties.confidence', -1), ('_created', -1)],
 | 
			
		||||
        }, api=api)
 | 
			
		||||
 | 
			
		||||
        enrich(comment)
 | 
			
		||||
 
 | 
			
		||||
@@ -19,6 +19,7 @@ from pillar.web.nodes.routes import url_for_node
 | 
			
		||||
from pillar.web.nodes.forms import get_node_form
 | 
			
		||||
import pillar.web.nodes.attachments
 | 
			
		||||
from pillar.web.projects.routes import project_update_nodes_list
 | 
			
		||||
from pillar.web.projects.routes import project_navigation_links
 | 
			
		||||
 | 
			
		||||
log = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
@@ -107,11 +108,7 @@ def posts_view(project_id=None, project_url=None, url=None, *, archive=False, pa
 | 
			
		||||
    else:
 | 
			
		||||
        project.blog_archive_prev = None
 | 
			
		||||
 | 
			
		||||
    title = 'blog_main' if is_main_project else 'blog'
 | 
			
		||||
 | 
			
		||||
    pages = Node.all({
 | 
			
		||||
        'where': {'project': project._id, 'node_type': 'page'},
 | 
			
		||||
        'projection': {'name': 1}}, api=api)
 | 
			
		||||
    navigation_links = project_navigation_links(project, api)
 | 
			
		||||
 | 
			
		||||
    return render_template(
 | 
			
		||||
        template_path,
 | 
			
		||||
@@ -121,10 +118,9 @@ def posts_view(project_id=None, project_url=None, url=None, *, archive=False, pa
 | 
			
		||||
        posts_meta=pmeta,
 | 
			
		||||
        more_posts_available=pmeta['total'] > pmeta['max_results'],
 | 
			
		||||
        project=project,
 | 
			
		||||
        title=title,
 | 
			
		||||
        node_type_post=project.get_node_type('post'),
 | 
			
		||||
        can_create_blog_posts=can_create_blog_posts,
 | 
			
		||||
        pages=pages._items,
 | 
			
		||||
        navigation_links=navigation_links,
 | 
			
		||||
        api=api)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -303,7 +303,7 @@ def view(project_url):
 | 
			
		||||
                                         'header_video_node': header_video_node})
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def project_navigation_links(project, api) -> list:
 | 
			
		||||
def project_navigation_links(project: typing.Type[Project], api) -> list:
 | 
			
		||||
    """Returns a list of nodes for the project, for top navigation display.
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
@@ -330,7 +330,7 @@ def project_navigation_links(project, api) -> list:
 | 
			
		||||
    }, api=api)
 | 
			
		||||
 | 
			
		||||
    if blog:
 | 
			
		||||
        links.append({'url': finders.find_url_for_node(blog), 'label': blog.name})
 | 
			
		||||
        links.append({'url': finders.find_url_for_node(blog), 'label': blog.name, 'slug': 'blog'})
 | 
			
		||||
 | 
			
		||||
    # Fetch pages
 | 
			
		||||
    pages = Node.all({
 | 
			
		||||
@@ -343,8 +343,7 @@ def project_navigation_links(project, api) -> list:
 | 
			
		||||
 | 
			
		||||
    # 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})
 | 
			
		||||
        links.append({'url': finders.find_url_for_node(p), 'label': p.name, 'slug': p.properties.url})
 | 
			
		||||
 | 
			
		||||
    return links
 | 
			
		||||
 | 
			
		||||
@@ -362,6 +361,7 @@ def render_project(project, api, extra_context=None, template_name=None):
 | 
			
		||||
        # Construct query parameters outside the loop.
 | 
			
		||||
        projection = {'name': 1, 'user': 1, 'node_type': 1, 'project': 1,
 | 
			
		||||
                      'properties.url': 1, 'properties.content_type': 1,
 | 
			
		||||
                      'properties.duration_seconds': 1,
 | 
			
		||||
                      'picture': 1}
 | 
			
		||||
        params = {'projection': projection, 'embedded': {'user': 1}}
 | 
			
		||||
 | 
			
		||||
@@ -466,6 +466,7 @@ def view_node(project_url, node_id):
 | 
			
		||||
    api = system_util.pillar_api()
 | 
			
		||||
    # First we check if it's a simple string, in which case we are looking for
 | 
			
		||||
    # a static page. Maybe we could use bson.objectid.ObjectId.is_valid(node_id)
 | 
			
		||||
    project: typing.Optional[Project] = None
 | 
			
		||||
    if not utils.is_valid_id(node_id):
 | 
			
		||||
        # raise wz_exceptions.NotFound('No such node')
 | 
			
		||||
        project, node = render_node_page(project_url, node_id, api)
 | 
			
		||||
@@ -483,21 +484,21 @@ def view_node(project_url, node_id):
 | 
			
		||||
            project = Project.find_one({'where': {"url": project_url, '_id': node.project}},
 | 
			
		||||
                                       api=api)
 | 
			
		||||
        except ResourceNotFound:
 | 
			
		||||
            # In theatre mode, we don't need access to the project at all.
 | 
			
		||||
            if theatre_mode:
 | 
			
		||||
                project = None
 | 
			
		||||
                pass  # In theatre mode, we don't need access to the project at all.
 | 
			
		||||
            else:
 | 
			
		||||
                raise wz_exceptions.NotFound('No such project')
 | 
			
		||||
 | 
			
		||||
    navigation_links = []
 | 
			
		||||
    og_picture = node.picture = utils.get_file(node.picture, api=api)
 | 
			
		||||
    if project:
 | 
			
		||||
        if not node.picture:
 | 
			
		||||
            og_picture = utils.get_file(project.picture_header, api=api)
 | 
			
		||||
        project.picture_square = utils.get_file(project.picture_square, api=api)
 | 
			
		||||
        navigation_links = project_navigation_links(project, api)
 | 
			
		||||
 | 
			
		||||
    # 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':
 | 
			
		||||
        return render_template('nodes/custom/page/view_embed.html',
 | 
			
		||||
 
 | 
			
		||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							@@ -1,7 +1,7 @@
 | 
			
		||||
# Primary requirements
 | 
			
		||||
-r ../pillar-python-sdk/requirements.txt
 | 
			
		||||
 | 
			
		||||
attrs==16.2.0
 | 
			
		||||
attrs==18.2.0
 | 
			
		||||
algoliasearch==1.12.0
 | 
			
		||||
bcrypt==3.1.3
 | 
			
		||||
blinker==1.4
 | 
			
		||||
@@ -14,6 +14,7 @@ Eve==0.8
 | 
			
		||||
Flask==1.0.2
 | 
			
		||||
Flask-Babel==0.11.2
 | 
			
		||||
Flask-Caching==1.4.0
 | 
			
		||||
Flask-DebugToolbar==0.10.1
 | 
			
		||||
Flask-Script==2.0.6
 | 
			
		||||
Flask-Login==0.4.1
 | 
			
		||||
Flask-WTF==0.14.2
 | 
			
		||||
 
 | 
			
		||||
@@ -11,10 +11,8 @@ $(document).ready(function() {
 | 
			
		||||
    var what = '';
 | 
			
		||||
 | 
			
		||||
    // Templates binding
 | 
			
		||||
    var hitTemplate = Hogan.compile($('#hit-template').text());
 | 
			
		||||
    var statsTemplate = Hogan.compile($('#stats-template').text());
 | 
			
		||||
    var facetTemplate = Hogan.compile($('#facet-template').text());
 | 
			
		||||
    var sliderTemplate = Hogan.compile($('#slider-template').text());
 | 
			
		||||
    var paginationTemplate = Hogan.compile($('#pagination-template').text());
 | 
			
		||||
 | 
			
		||||
    // defined in tutti/4_search.js
 | 
			
		||||
@@ -47,6 +45,7 @@ $(document).ready(function() {
 | 
			
		||||
        renderFacets(content);
 | 
			
		||||
        renderPagination(content);
 | 
			
		||||
        renderFirstHit($(hits).children('.search-hit:first'));
 | 
			
		||||
        updateUrlParams();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    /***************
 | 
			
		||||
@@ -66,7 +65,7 @@ $(document).ready(function() {
 | 
			
		||||
 | 
			
		||||
        window.setTimeout(function() {
 | 
			
		||||
            // Ignore getting that first result when there is none.
 | 
			
		||||
            var hit_id = firstHit.attr('data-hit-id');
 | 
			
		||||
            var hit_id = firstHit.attr('data-node-id');
 | 
			
		||||
            if (hit_id === undefined) {
 | 
			
		||||
                done();
 | 
			
		||||
                return;
 | 
			
		||||
@@ -87,12 +86,6 @@ $(document).ready(function() {
 | 
			
		||||
    // Initial search
 | 
			
		||||
    initWithUrlParams();
 | 
			
		||||
 | 
			
		||||
    function convertTimestamp(iso8601) {
 | 
			
		||||
        var d = new Date(iso8601)
 | 
			
		||||
        return d.toLocaleDateString();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    function renderStats(content) {
 | 
			
		||||
        var stats = {
 | 
			
		||||
            nbHits: numberWithDelimiter(content.count),
 | 
			
		||||
@@ -103,20 +96,17 @@ $(document).ready(function() {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function renderHits(content) {
 | 
			
		||||
        var hitsHtml = '';
 | 
			
		||||
        for (var i = 0; i < content.hits.length; ++i) {
 | 
			
		||||
            var created = content.hits[i].created_at;
 | 
			
		||||
            if (created) {
 | 
			
		||||
                content.hits[i].created_at = convertTimestamp(created);
 | 
			
		||||
            }
 | 
			
		||||
            var updated = content.hits[i].updated_at;
 | 
			
		||||
            if (updated) {
 | 
			
		||||
                content.hits[i].updated_at = convertTimestamp(updated);
 | 
			
		||||
            }
 | 
			
		||||
            hitsHtml += hitTemplate.render(content.hits[i]);
 | 
			
		||||
        $hits.empty();
 | 
			
		||||
        if (content.hits.length === 0) {
 | 
			
		||||
            $hits.html('<p id="no-hits">We didn\'t find any items. Try searching something else.</p>');
 | 
			
		||||
        }
 | 
			
		||||
        else {
 | 
			
		||||
            listof$hits = content.hits.map(function(hit){
 | 
			
		||||
                return pillar.templates.Component.create$listItem(hit)
 | 
			
		||||
                    .addClass('js-search-hit cursor-pointer search-hit');
 | 
			
		||||
            })
 | 
			
		||||
            $hits.append(listof$hits);
 | 
			
		||||
        }
 | 
			
		||||
        if (content.hits.length === 0) hitsHtml = '<p id="no-hits">We didn\'t find any items. Try searching something else.</p>';
 | 
			
		||||
        $hits.html(hitsHtml);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function renderFacets(content) {
 | 
			
		||||
@@ -133,7 +123,7 @@ $(document).ready(function() {
 | 
			
		||||
                var refined = search.isRefined(label, item.key);
 | 
			
		||||
                values.push({
 | 
			
		||||
                    facet: label,
 | 
			
		||||
                    label: item.key,
 | 
			
		||||
                    label: item.key_as_string || item.key,
 | 
			
		||||
                    value: item.key,
 | 
			
		||||
                    count: item.doc_count,
 | 
			
		||||
                    refined: refined,
 | 
			
		||||
@@ -153,7 +143,7 @@ $(document).ready(function() {
 | 
			
		||||
 | 
			
		||||
            buckets.forEach(storeValue(values, label));
 | 
			
		||||
            facets.push({
 | 
			
		||||
                title: label,
 | 
			
		||||
                title: removeUnderscore(label),
 | 
			
		||||
                values: values.slice(0),
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
@@ -218,6 +208,9 @@ $(document).ready(function() {
 | 
			
		||||
        $pagination.html(paginationTemplate.render(pagination));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function removeUnderscore(s) {
 | 
			
		||||
    	return s.replace(/_/g, ' ')
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Event bindings
 | 
			
		||||
    // Click binding
 | 
			
		||||
@@ -300,37 +293,46 @@ $(document).ready(function() {
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    function initWithUrlParams() {
 | 
			
		||||
        var sPageURL = location.hash;
 | 
			
		||||
        if (!sPageURL || sPageURL.length === 0) {
 | 
			
		||||
            return true;
 | 
			
		||||
        var pageURL = decodeURIComponent(window.location.search.substring(1)),
 | 
			
		||||
            urlVariables = pageURL.split('&'),
 | 
			
		||||
            query,
 | 
			
		||||
            i;
 | 
			
		||||
        for (i = 0; i < urlVariables.length; i++) {
 | 
			
		||||
            var parameterPair = urlVariables[i].split('='),
 | 
			
		||||
                key = parameterPair[0],
 | 
			
		||||
                sValue = parameterPair[1];
 | 
			
		||||
            if (!key) continue;
 | 
			
		||||
            if (key === 'q') {
 | 
			
		||||
                query = sValue;
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
            if (key === 'page') {
 | 
			
		||||
                var page = Number.parseInt(sValue)
 | 
			
		||||
                search.setCurrentPage(isNaN(page) ? 0 : page)
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
            if (key === 'project') {
 | 
			
		||||
                continue;  // We take the project from the path
 | 
			
		||||
            }
 | 
			
		||||
            if (sValue !== undefined) {
 | 
			
		||||
            	var iValue = Number.parseInt(sValue),
 | 
			
		||||
            	    value = isNaN(iValue) ? sValue : iValue;
 | 
			
		||||
                search.toggleTerm(key, value);
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
            console.log('Unhandled url parameter pair:', parameterPair)
 | 
			
		||||
        }
 | 
			
		||||
        var sURLVariables = sPageURL.split('&');
 | 
			
		||||
        if (!sURLVariables || sURLVariables.length === 0) {
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
        var query = decodeURIComponent(sURLVariables[0].split('=')[1]);
 | 
			
		||||
        $inputField.val(query);
 | 
			
		||||
        search.setQuery(query, what);
 | 
			
		||||
 | 
			
		||||
        for (var i = 2; i < sURLVariables.length; i++) {
 | 
			
		||||
            var sParameterName = sURLVariables[i].split('=');
 | 
			
		||||
            var facet = decodeURIComponent(sParameterName[0]);
 | 
			
		||||
            var value = decodeURIComponent(sParameterName[1]);
 | 
			
		||||
        }
 | 
			
		||||
        // Page has to be set in the end to avoid being overwritten
 | 
			
		||||
        var page = decodeURIComponent(sURLVariables[1].split('=')[1]) - 1;
 | 
			
		||||
        search.setCurrentPage(page);
 | 
			
		||||
        do_search(query || '');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function setURLParams(state) {
 | 
			
		||||
        var urlParams = '?';
 | 
			
		||||
        var currentQuery = state.query;
 | 
			
		||||
        urlParams += 'q=' + encodeURIComponent(currentQuery);
 | 
			
		||||
        var currentPage = state.page + 1;
 | 
			
		||||
        urlParams += '&page=' + currentPage;
 | 
			
		||||
        location.replace(urlParams);
 | 
			
		||||
    function updateUrlParams() {
 | 
			
		||||
        var prevState = history.state,
 | 
			
		||||
            prevTitle = document.title,
 | 
			
		||||
            params = search.getParams(),
 | 
			
		||||
            newUrl = window.location.pathname + '?';
 | 
			
		||||
        delete params['project']  // We take the project from the path
 | 
			
		||||
        newUrl += jQuery.param(params)
 | 
			
		||||
        history.replaceState(prevState, prevTitle, newUrl);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // do empty search to fill aggregations
 | 
			
		||||
    do_search('');
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										58
									
								
								src/scripts/js/es6/common/quicksearch/MultiSearch.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								src/scripts/js/es6/common/quicksearch/MultiSearch.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,58 @@
 | 
			
		||||
import {SearchParams} from './SearchParams';
 | 
			
		||||
 | 
			
		||||
export class MultiSearch {
 | 
			
		||||
    constructor(kwargs) {
 | 
			
		||||
        this.uiUrl = kwargs['uiUrl']; // Url for advanced search
 | 
			
		||||
        this.apiUrl = kwargs['apiUrl']; // Url for api calls
 | 
			
		||||
        this.searchParams = MultiSearch.createMultiSearchParams(kwargs['searchParams']);
 | 
			
		||||
        this.q = '';
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    setSearchWord(q) {
 | 
			
		||||
        this.q = q;
 | 
			
		||||
        this.searchParams.forEach((qsParam) => {
 | 
			
		||||
            qsParam.setSearchWord(q);
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getSearchUrl() {
 | 
			
		||||
        return this.uiUrl + '?q=' + this.q;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getAllParams() {
 | 
			
		||||
        let retval = $.map(this.searchParams, (msParams) => {
 | 
			
		||||
            return msParams.params;
 | 
			
		||||
        });
 | 
			
		||||
        return retval;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    parseResult(rawResult) {
 | 
			
		||||
        return $.map(rawResult, (subResult, index) => {
 | 
			
		||||
            let name = this.searchParams[index].name;
 | 
			
		||||
            let pStr = this.searchParams[index].getParamStr();
 | 
			
		||||
            let result = $.map(subResult.hits.hits, (hit) => {
 | 
			
		||||
                return hit._source;
 | 
			
		||||
            });
 | 
			
		||||
            return {
 | 
			
		||||
                name: name,
 | 
			
		||||
                url: this.uiUrl + '?' + pStr,
 | 
			
		||||
                result: result,
 | 
			
		||||
                hasResults: !!result.length
 | 
			
		||||
            };
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    thenExecute() {
 | 
			
		||||
        let data = JSON.stringify(this.getAllParams());
 | 
			
		||||
        let rawAjax = $.getJSON(this.apiUrl, data);
 | 
			
		||||
        let prettyPromise = rawAjax.then(this.parseResult.bind(this));
 | 
			
		||||
        prettyPromise['abort'] = rawAjax.abort.bind(rawAjax); // Hack to be able to abort the promise down the road
 | 
			
		||||
        return prettyPromise;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    static createMultiSearchParams(argsList) {
 | 
			
		||||
        return $.map(argsList, (args) => {
 | 
			
		||||
            return new SearchParams(args);
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										204
									
								
								src/scripts/js/es6/common/quicksearch/QuickSearch.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										204
									
								
								src/scripts/js/es6/common/quicksearch/QuickSearch.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,204 @@
 | 
			
		||||
import { create$noHits, create$results, create$input } from './templates'
 | 
			
		||||
import {SearchFacade} from './SearchFacade';
 | 
			
		||||
/**
 | 
			
		||||
 *  QuickSearch             : Interacts with the dom document
 | 
			
		||||
 *    1-SearchFacade        : Controls which multisearch is active
 | 
			
		||||
 *      *-MultiSearch       : One multi search is typically Project or Cloud
 | 
			
		||||
 *        *-SearchParams    : The search params for the individual searches
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
export class QuickSearch {
 | 
			
		||||
    /**
 | 
			
		||||
     * Interacts with the dom document and deligates the input down to the SearchFacade
 | 
			
		||||
     * @param {selector string} searchToggle The quick-search toggle
 | 
			
		||||
     * @param {*} kwargs 
 | 
			
		||||
     */
 | 
			
		||||
    constructor(searchToggle, kwargs) {
 | 
			
		||||
        this.$body = $('body');
 | 
			
		||||
        this.$quickSearch = $('.quick-search');
 | 
			
		||||
        this.$inputComponent = $(kwargs['inputTarget']);
 | 
			
		||||
        this.$inputComponent.empty();
 | 
			
		||||
        this.$inputComponent.append(create$input(kwargs['searches']));
 | 
			
		||||
        this.$searchInput = this.$inputComponent.find('input');
 | 
			
		||||
        this.$searchSelect = this.$inputComponent.find('select');
 | 
			
		||||
        this.$resultTarget = $(kwargs['resultTarget']);
 | 
			
		||||
        this.$searchSymbol = this.$inputComponent.find('.qs-busy-symbol');
 | 
			
		||||
        this.searchFacade = new SearchFacade(kwargs['searches'] || {});
 | 
			
		||||
        this.$searchToggle = $(searchToggle);
 | 
			
		||||
        this.isBusy = false;
 | 
			
		||||
        this.attach();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    attach() {
 | 
			
		||||
        if (this.$searchSelect.length) {
 | 
			
		||||
            this.$searchSelect
 | 
			
		||||
                .change(this.execute.bind(this))
 | 
			
		||||
                .change(() => this.$searchInput.focus());
 | 
			
		||||
            this.$searchInput.addClass('multi-scope');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.$searchInput
 | 
			
		||||
            .keyup(this.onInputKeyUp.bind(this));
 | 
			
		||||
 | 
			
		||||
        this.$inputComponent
 | 
			
		||||
            .on('pillar:workStart', () => {
 | 
			
		||||
                this.$searchSymbol.addClass('spinner')
 | 
			
		||||
                this.$searchSymbol.toggleClass('pi-spin pi-cancel')
 | 
			
		||||
            })
 | 
			
		||||
            .on('pillar:workStop', () => {
 | 
			
		||||
                this.$searchSymbol.removeClass('spinner')
 | 
			
		||||
                this.$searchSymbol.toggleClass('pi-spin pi-cancel')
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
        this.searchFacade.setOnResultCB(this.renderResult.bind(this));
 | 
			
		||||
        this.searchFacade.setOnFailureCB(this.onSearchFailed.bind(this));
 | 
			
		||||
        this.$searchToggle
 | 
			
		||||
            .one('click', this.execute.bind(this));  // Initial search executed once
 | 
			
		||||
            
 | 
			
		||||
        this.registerShowGui();
 | 
			
		||||
        this.registerHideGui();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    registerShowGui() {
 | 
			
		||||
        this.$searchToggle
 | 
			
		||||
            .click((e) => {
 | 
			
		||||
                this.showGUI();
 | 
			
		||||
                e.stopPropagation();
 | 
			
		||||
            });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    registerHideGui() {
 | 
			
		||||
        this.$searchSymbol
 | 
			
		||||
            .click(() => {
 | 
			
		||||
                this.hideGUI();
 | 
			
		||||
            });
 | 
			
		||||
        this.$body.click((e) => {
 | 
			
		||||
            let $target = $(e.target);
 | 
			
		||||
            let isClickInResult = $target.hasClass('.qs-result') || !!$target.parents('.qs-result').length;
 | 
			
		||||
            let isClickInInput = $target.hasClass('.qs-input') || !!$target.parents('.qs-input').length;
 | 
			
		||||
            if (!isClickInResult && !isClickInInput) {
 | 
			
		||||
                this.hideGUI();
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
        $(document).keyup((e) => {
 | 
			
		||||
            if (e.key === 'Escape') {
 | 
			
		||||
                this.hideGUI();
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    showGUI() {
 | 
			
		||||
        this.$body.addClass('has-overlay');
 | 
			
		||||
        this.$quickSearch.trigger('pillar:searchShow');
 | 
			
		||||
        this.$quickSearch.addClass('show');
 | 
			
		||||
        if (!this.$searchInput.is(':focus')) {
 | 
			
		||||
            this.$searchInput.focus();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    hideGUI() {
 | 
			
		||||
        this.$body.removeClass('has-overlay');
 | 
			
		||||
        this.$searchToggle.addClass('pi-search');
 | 
			
		||||
        this.$searchInput.blur();
 | 
			
		||||
        this.$quickSearch.removeClass('show');
 | 
			
		||||
        this.$quickSearch.trigger('pillar:searchHidden');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    onInputKeyUp(e) {
 | 
			
		||||
        let newQ = this.$searchInput.val();
 | 
			
		||||
        let currQ = this.searchFacade.getSearchWord();
 | 
			
		||||
        this.searchFacade.setSearchWord(newQ);
 | 
			
		||||
        let searchUrl = this.searchFacade.getSearchUrl();
 | 
			
		||||
        if (e.key === 'Enter') {
 | 
			
		||||
            window.location.href = searchUrl;
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        if (newQ !== currQ) {
 | 
			
		||||
            this.execute();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    execute() {
 | 
			
		||||
        this.busy(true);
 | 
			
		||||
        let scope = this.getScope();
 | 
			
		||||
        this.searchFacade.setCurrentScope(scope);
 | 
			
		||||
        let q = this.$searchInput.val();
 | 
			
		||||
        this.searchFacade.setSearchWord(q);
 | 
			
		||||
        this.searchFacade.execute();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    renderResult(results) {
 | 
			
		||||
        this.$resultTarget.empty();
 | 
			
		||||
        this.$resultTarget.append(this.create$result(results));
 | 
			
		||||
        this.busy(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    create$result(results) {
 | 
			
		||||
        let withHits = results.reduce((aggr, subResult) => {
 | 
			
		||||
            if (subResult.hasResults) {
 | 
			
		||||
                aggr.push(subResult);
 | 
			
		||||
            }
 | 
			
		||||
            return aggr;
 | 
			
		||||
        }, []);
 | 
			
		||||
 | 
			
		||||
        if (!withHits.length) {
 | 
			
		||||
            return create$noHits(this.searchFacade.getSearchUrl());
 | 
			
		||||
        }
 | 
			
		||||
        return create$results(results, this.searchFacade.getSearchUrl());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    onSearchFailed(err) {
 | 
			
		||||
        toastr.error(xhrErrorResponseMessage(err), 'Unable to perform search:');
 | 
			
		||||
        this.busy(false);
 | 
			
		||||
        this.$inputComponent.trigger('pillar:failed', err);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getScope() {
 | 
			
		||||
        return !!this.$searchSelect.length ? this.$searchSelect.val() : 'cloud';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    busy(val) {
 | 
			
		||||
        if (val !== this.isBusy) {
 | 
			
		||||
            var eventType = val ? 'pillar:workStart' : 'pillar:workStop';
 | 
			
		||||
            this.$inputComponent.trigger(eventType);
 | 
			
		||||
        }
 | 
			
		||||
        this.isBusy = val;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
$.fn.extend({
 | 
			
		||||
    /**
 | 
			
		||||
     * $('#qs-toggle').quickSearch({
 | 
			
		||||
     *          resultTarget: '#search-overlay',
 | 
			
		||||
     *          inputTarget: '#qs-input',
 | 
			
		||||
     *          searches: {
 | 
			
		||||
     *          project: {
 | 
			
		||||
     *              name: 'Project', 
 | 
			
		||||
     *              uiUrl: '{{ url_for("projects.search", project_url=project.url)}}',
 | 
			
		||||
     *              apiUrl: '/api/newsearch/multisearch',
 | 
			
		||||
     *              searchParams: [
 | 
			
		||||
     *                  {name: 'Assets', params: {project: '{{ project._id }}', node_type: 'asset'}},
 | 
			
		||||
     *                  {name: 'Blog', params: {project: '{{ project._id }}', node_type: 'post'}},
 | 
			
		||||
     *                  {name: 'Groups', params: {project: '{{ project._id }}', node_type: 'group'}},
 | 
			
		||||
     *              ]
 | 
			
		||||
     *          },
 | 
			
		||||
     *          cloud: {
 | 
			
		||||
     *              name: 'Cloud',
 | 
			
		||||
     *              uiUrl: '/search',
 | 
			
		||||
     *              apiUrl: '/api/newsearch/multisearch',
 | 
			
		||||
     *              searchParams: [
 | 
			
		||||
     *                  {name: 'Assets', params: {node_type: 'asset'}},
 | 
			
		||||
     *                  {name: 'Blog', params: {node_type: 'post'}},
 | 
			
		||||
     *                  {name: 'Groups', params: {node_type: 'group'}},
 | 
			
		||||
     *              ]
 | 
			
		||||
     *          },
 | 
			
		||||
     *      },
 | 
			
		||||
     *  });
 | 
			
		||||
     * @param {*} kwargs 
 | 
			
		||||
     */
 | 
			
		||||
    quickSearch: function (kwargs) {
 | 
			
		||||
        $(this).each((i, qsElem) => {
 | 
			
		||||
            new QuickSearch(qsElem, kwargs);
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
})
 | 
			
		||||
							
								
								
									
										68
									
								
								src/scripts/js/es6/common/quicksearch/SearchFacade.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								src/scripts/js/es6/common/quicksearch/SearchFacade.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,68 @@
 | 
			
		||||
import {MultiSearch} from './MultiSearch';
 | 
			
		||||
 | 
			
		||||
export class SearchFacade {
 | 
			
		||||
    /**
 | 
			
		||||
     * One SearchFacade holds n-number of MultiSearch objects, and delegates search requests to the active mutlisearch
 | 
			
		||||
     * @param {*} kwargs 
 | 
			
		||||
     */
 | 
			
		||||
    constructor(kwargs) {
 | 
			
		||||
        this.searches = SearchFacade.createMultiSearches(kwargs);
 | 
			
		||||
        this.currentScope = 'cloud'; // which multisearch to use
 | 
			
		||||
        this.currRequest;
 | 
			
		||||
        this.resultCB;
 | 
			
		||||
        this.failureCB;
 | 
			
		||||
        this.q = '';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setSearchWord(q) {
 | 
			
		||||
        this.q = q;
 | 
			
		||||
        $.each(this.searches, (k, mSearch) => {
 | 
			
		||||
            mSearch.setSearchWord(q);
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getSearchWord() {
 | 
			
		||||
        return this.q;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getSearchUrl() {
 | 
			
		||||
        return this.searches[this.currentScope].getSearchUrl();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setCurrentScope(scope) {
 | 
			
		||||
        this.currentScope = scope;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    execute() {
 | 
			
		||||
        if (this.currRequest) {
 | 
			
		||||
            this.currRequest.abort();
 | 
			
		||||
        }
 | 
			
		||||
        this.currRequest = this.searches[this.currentScope].thenExecute();
 | 
			
		||||
        this.currRequest
 | 
			
		||||
            .then((results) => {
 | 
			
		||||
                this.resultCB(results);
 | 
			
		||||
            })
 | 
			
		||||
            .fail((err, reason) => {
 | 
			
		||||
                if (reason == 'abort') {
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
                this.failureCB(err);
 | 
			
		||||
            });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setOnResultCB(cb) {
 | 
			
		||||
        this.resultCB = cb;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setOnFailureCB(cb) {
 | 
			
		||||
        this.failureCB = cb;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    static createMultiSearches(kwargs) {
 | 
			
		||||
        var searches = {};
 | 
			
		||||
        $.each(kwargs, (key, value) => {
 | 
			
		||||
            searches[key] = new MultiSearch(value);
 | 
			
		||||
        });
 | 
			
		||||
        return searches;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										14
									
								
								src/scripts/js/es6/common/quicksearch/SearchParams.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/scripts/js/es6/common/quicksearch/SearchParams.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,14 @@
 | 
			
		||||
export class SearchParams {
 | 
			
		||||
    constructor(kwargs) {
 | 
			
		||||
        this.name = kwargs['name'] || '';
 | 
			
		||||
        this.params = kwargs['params'] || {};
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setSearchWord(q) {
 | 
			
		||||
        this.params['q'] = q || '';
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    getParamStr() {
 | 
			
		||||
        return jQuery.param(this.params);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								src/scripts/js/es6/common/quicksearch/init.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/scripts/js/es6/common/quicksearch/init.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
export { QuickSearch } from './QuickSearch';
 | 
			
		||||
							
								
								
									
										93
									
								
								src/scripts/js/es6/common/quicksearch/templates.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								src/scripts/js/es6/common/quicksearch/templates.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,93 @@
 | 
			
		||||
/**
 | 
			
		||||
 * Creates the jQuery object that is rendered when nothing is found
 | 
			
		||||
 * @param {String} advancedUrl Url to the advanced search with the current query
 | 
			
		||||
 * @returns {$element} The jQuery element that is rendered wher there are no hits
 | 
			
		||||
 */
 | 
			
		||||
function create$noHits(advancedUrl) {
 | 
			
		||||
    return $('<div>')
 | 
			
		||||
        .addClass('qs-msg text-center p-3')
 | 
			
		||||
		.append(
 | 
			
		||||
            $('<div>')
 | 
			
		||||
                .addClass('h1 pi-displeased'),
 | 
			
		||||
            $('<div>')
 | 
			
		||||
                .addClass('h2')
 | 
			
		||||
                .append(
 | 
			
		||||
                    $('<a>')
 | 
			
		||||
                    .attr('href', advancedUrl)
 | 
			
		||||
                    .text('Advanced search')
 | 
			
		||||
                )
 | 
			
		||||
        )
 | 
			
		||||
}
 | 
			
		||||
/**
 | 
			
		||||
 * Creates the jQuery object that is rendered as the search input
 | 
			
		||||
 * @param {Dict} searches The searches dict that is passed in on construction of the Quick-Search
 | 
			
		||||
 * @returns {$element} The jQuery object that renders the search input components.
 | 
			
		||||
 */
 | 
			
		||||
function create$input(searches) {
 | 
			
		||||
    let input = $('<input>')
 | 
			
		||||
        .addClass('qs-input')
 | 
			
		||||
        .attr('type', 'search')
 | 
			
		||||
        .attr('autocomplete', 'off')
 | 
			
		||||
        .attr('spellcheck', 'false')
 | 
			
		||||
        .attr('autocorrect', 'false')
 | 
			
		||||
        .attr('placeholder', 'Search...');
 | 
			
		||||
    let workingSymbol = $('<i>')
 | 
			
		||||
        .addClass('pi-cancel qs-busy-symbol');
 | 
			
		||||
    let inputComponent = [input, workingSymbol];
 | 
			
		||||
	if (Object.keys(searches).length > 1) {
 | 
			
		||||
        let i = 0;
 | 
			
		||||
        let select = $('<select>')
 | 
			
		||||
        .append(
 | 
			
		||||
            $.map(searches, (it, value) => {
 | 
			
		||||
                let option = $('<option>')
 | 
			
		||||
                .attr('value', value)
 | 
			
		||||
                .text(it['name']);
 | 
			
		||||
                if (i === 0) {
 | 
			
		||||
                    option.attr('selected', 'selected');
 | 
			
		||||
                }
 | 
			
		||||
                i += 1;
 | 
			
		||||
                return option;
 | 
			
		||||
            })
 | 
			
		||||
        );
 | 
			
		||||
        inputComponent.push(select);
 | 
			
		||||
    }
 | 
			
		||||
    return inputComponent;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Creates the search result
 | 
			
		||||
 * @param {List} results
 | 
			
		||||
 * @param {String} advancedUrl
 | 
			
		||||
 * @returns {$element} The jQuery object that is rendered as the result
 | 
			
		||||
 */
 | 
			
		||||
function create$results(results, advancedUrl) {
 | 
			
		||||
    let $results = results.reduce((agg, res)=> {
 | 
			
		||||
        if(res['result'].length) {
 | 
			
		||||
            agg.push(
 | 
			
		||||
                $('<a>')
 | 
			
		||||
                    .addClass('h4 mt-4 d-flex')
 | 
			
		||||
                    .attr('href', res['url'])
 | 
			
		||||
                    .text(res['name'])
 | 
			
		||||
            )
 | 
			
		||||
            agg.push(
 | 
			
		||||
                $('<div>')
 | 
			
		||||
                    .addClass('card-deck card-deck-responsive card-padless js-asset-list p-3')
 | 
			
		||||
                    .append(
 | 
			
		||||
                        ...pillar.templates.Nodes.createListOf$nodeItems(res['result'], 10, 0)
 | 
			
		||||
                    )
 | 
			
		||||
            )
 | 
			
		||||
        }
 | 
			
		||||
        return agg;
 | 
			
		||||
    }, [])
 | 
			
		||||
    $results.push(
 | 
			
		||||
        $('<a>')
 | 
			
		||||
            .attr('href', advancedUrl)
 | 
			
		||||
            .text('Advanced search...')
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    return $('<div>')
 | 
			
		||||
        .addClass('m-auto qs-result')
 | 
			
		||||
        .append(...$results)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { create$noHits, create$results, create$input }
 | 
			
		||||
							
								
								
									
										124
									
								
								src/scripts/js/es6/common/templates/__tests__/Assets.test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								src/scripts/js/es6/common/templates/__tests__/Assets.test.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,124 @@
 | 
			
		||||
import { Assets } from '../nodes/Assets'
 | 
			
		||||
 | 
			
		||||
jest.useFakeTimers();
 | 
			
		||||
 | 
			
		||||
describe('Assets', () => {
 | 
			
		||||
    describe('create$listItem', () => {
 | 
			
		||||
        let nodeDoc;
 | 
			
		||||
        let spyGet;
 | 
			
		||||
        beforeEach(()=>{
 | 
			
		||||
            // mock now to get a stable pretty printed created
 | 
			
		||||
            Date.now = jest.fn(() => new Date(Date.UTC(2018,
 | 
			
		||||
                10, //November! zero based month!
 | 
			
		||||
                28, 11, 46, 30)).valueOf()); // A Tuesday
 | 
			
		||||
 | 
			
		||||
            nodeDoc = {
 | 
			
		||||
                _id: 'my-asset-id',
 | 
			
		||||
                name: 'My Asset',
 | 
			
		||||
                node_type: 'asset',
 | 
			
		||||
                _created: "Wed, 07 Nov 2018 16:35:09 GMT",
 | 
			
		||||
                project: {
 | 
			
		||||
                    name: 'My Project',
 | 
			
		||||
                    url: 'url-to-project'
 | 
			
		||||
                },
 | 
			
		||||
                properties: {
 | 
			
		||||
                    content_type: 'image'
 | 
			
		||||
                }
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            spyGet = spyOn($, 'get').and.callFake(function(url) {
 | 
			
		||||
                let ajaxMock = $.Deferred();
 | 
			
		||||
                let response = {
 | 
			
		||||
                    variations: [{
 | 
			
		||||
                        size: 'l',
 | 
			
		||||
                        link: 'wrong-img-link',
 | 
			
		||||
                        width: 150,
 | 
			
		||||
                        height: 170,
 | 
			
		||||
                    },{
 | 
			
		||||
                        size: 'm',
 | 
			
		||||
                        link: 'img-link',
 | 
			
		||||
                        width: 50,
 | 
			
		||||
                        height: 70,
 | 
			
		||||
                    },{
 | 
			
		||||
                        size: 's',
 | 
			
		||||
                        link: 'wrong-img-link',
 | 
			
		||||
                        width: 5,
 | 
			
		||||
                        height: 7,
 | 
			
		||||
                    }]
 | 
			
		||||
                }
 | 
			
		||||
                ajaxMock.resolve(response);
 | 
			
		||||
                return ajaxMock.promise();
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
        describe('image content', () => {
 | 
			
		||||
            test('node with picture', done => {
 | 
			
		||||
                nodeDoc.picture = 'picture_id';
 | 
			
		||||
                let $card = Assets.create$listItem(nodeDoc);
 | 
			
		||||
                jest.runAllTimers();
 | 
			
		||||
                expect($card.length).toEqual(1);
 | 
			
		||||
                expect($card.prop('tagName')).toEqual('A'); // <a>
 | 
			
		||||
                expect($card.hasClass('asset')).toBeTruthy();
 | 
			
		||||
                expect($card.hasClass('card')).toBeTruthy();
 | 
			
		||||
                expect($card.attr('href')).toEqual('/nodes/my-asset-id/redir');
 | 
			
		||||
                expect($card.attr('title')).toEqual('My Asset');
 | 
			
		||||
    
 | 
			
		||||
                let $body = $card.find('.card-body');
 | 
			
		||||
                expect($body.length).toEqual(1);
 | 
			
		||||
    
 | 
			
		||||
                let $title = $body.find('.card-title');
 | 
			
		||||
                expect($title.length).toEqual(1);
 | 
			
		||||
                
 | 
			
		||||
                expect(spyGet).toHaveBeenCalledTimes(1);
 | 
			
		||||
                expect(spyGet).toHaveBeenLastCalledWith('/api/files/picture_id');
 | 
			
		||||
 | 
			
		||||
                let $image = $card.find('img');
 | 
			
		||||
                expect($image.length).toEqual(1);
 | 
			
		||||
 | 
			
		||||
                let $imageSubsititure = $card.find('.pi-asset');
 | 
			
		||||
                expect($imageSubsititure.length).toEqual(0);
 | 
			
		||||
    
 | 
			
		||||
                let $progress = $card.find('.progress');
 | 
			
		||||
                expect($progress.length).toEqual(0);
 | 
			
		||||
 | 
			
		||||
                let $watched = $card.find('.card-label');
 | 
			
		||||
                expect($watched.length).toEqual(0);
 | 
			
		||||
 | 
			
		||||
                expect($card.find(':contains(3 weeks ago)').length).toBeTruthy();
 | 
			
		||||
                done();
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            test('node without picture', done => {
 | 
			
		||||
                let $card = Assets.create$listItem(nodeDoc);
 | 
			
		||||
                expect($card.length).toEqual(1);
 | 
			
		||||
                expect($card.prop('tagName')).toEqual('A'); // <a>
 | 
			
		||||
                expect($card.hasClass('asset')).toBeTruthy();
 | 
			
		||||
                expect($card.hasClass('card')).toBeTruthy();
 | 
			
		||||
                expect($card.attr('href')).toEqual('/nodes/my-asset-id/redir');
 | 
			
		||||
                expect($card.attr('title')).toEqual('My Asset');
 | 
			
		||||
    
 | 
			
		||||
                let $body = $card.find('.card-body');
 | 
			
		||||
                expect($body.length).toEqual(1);
 | 
			
		||||
    
 | 
			
		||||
                let $title = $body.find('.card-title');
 | 
			
		||||
                expect($title.length).toEqual(1);
 | 
			
		||||
 | 
			
		||||
                expect(spyGet).toHaveBeenCalledTimes(0);
 | 
			
		||||
 | 
			
		||||
                let $image = $card.find('img');
 | 
			
		||||
                expect($image.length).toEqual(0);
 | 
			
		||||
 | 
			
		||||
                let $imageSubsititure = $card.find('.pi-asset');
 | 
			
		||||
                expect($imageSubsititure.length).toEqual(1);
 | 
			
		||||
    
 | 
			
		||||
                let $progress = $card.find('.progress');
 | 
			
		||||
                expect($progress.length).toEqual(0);
 | 
			
		||||
 | 
			
		||||
                let $watched = $card.find('.card-label');
 | 
			
		||||
                expect($watched.length).toEqual(0);
 | 
			
		||||
                
 | 
			
		||||
                expect($card.find(':contains(3 weeks ago)').length).toBeTruthy();
 | 
			
		||||
                done();
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
    })
 | 
			
		||||
});
 | 
			
		||||
@@ -0,0 +1,48 @@
 | 
			
		||||
import { Assets } from '../nodes/Assets'
 | 
			
		||||
import { Users } from '../users/Users'
 | 
			
		||||
import { Component } from '../init' // Component is initialized in init
 | 
			
		||||
 | 
			
		||||
describe('Component', () => {
 | 
			
		||||
    test('can create Users listItem', () => {
 | 
			
		||||
        let userDoc = {
 | 
			
		||||
            _id: 'my-user-id',
 | 
			
		||||
            username: 'My User Name',
 | 
			
		||||
            full_name: 'My full name',
 | 
			
		||||
            roles: ['admin', 'subscriber']
 | 
			
		||||
        };
 | 
			
		||||
        
 | 
			
		||||
        let $user_actual = Component.create$listItem(userDoc);
 | 
			
		||||
        expect($user_actual.length).toBe(1);
 | 
			
		||||
        
 | 
			
		||||
        let $user_reference = Users.create$listItem(userDoc);
 | 
			
		||||
        expect($user_actual).toEqual($user_reference);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    test('can create Asset listItem', () => {
 | 
			
		||||
        let nodeDoc = {
 | 
			
		||||
            _id: 'my-asset-id',
 | 
			
		||||
            name: 'My Asset',
 | 
			
		||||
            node_type: 'asset',
 | 
			
		||||
            project: {
 | 
			
		||||
                name: 'My Project',
 | 
			
		||||
                url: 'url-to-project'
 | 
			
		||||
            },
 | 
			
		||||
            properties: {
 | 
			
		||||
                content_type: 'image'
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
        
 | 
			
		||||
        let $asset_actual = Component.create$listItem(nodeDoc);
 | 
			
		||||
        expect($asset_actual.length).toBe(1);
 | 
			
		||||
        
 | 
			
		||||
        let $asset_reference = Assets.create$listItem(nodeDoc);
 | 
			
		||||
        expect($asset_actual).toEqual($asset_reference);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    test('fail to create unknown', () => {
 | 
			
		||||
        expect(()=>Component.create$listItem({})).toThrow('Can not create component using: {}')
 | 
			
		||||
        expect(()=>Component.create$listItem()).toThrow('Can not create component using: undefined')
 | 
			
		||||
        expect(()=>Component.create$listItem({strange: 'value'}))
 | 
			
		||||
            .toThrow('Can not create component using: {"strange":"value"}')
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										67
									
								
								src/scripts/js/es6/common/templates/__tests__/utils.test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								src/scripts/js/es6/common/templates/__tests__/utils.test.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,67 @@
 | 
			
		||||
import { prettyDate } from '../utils'
 | 
			
		||||
 | 
			
		||||
describe('prettydate', () => {
 | 
			
		||||
    beforeEach(() => {
 | 
			
		||||
        Date.now = jest.fn(() => new Date(Date.UTC(2016,
 | 
			
		||||
            10, //November! zero based month!
 | 
			
		||||
            8, 11, 46, 30)).valueOf()); // A Tuesday
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    test('bad input', () => {
 | 
			
		||||
        expect(prettyDate(undefined)).toBeUndefined();
 | 
			
		||||
        expect(prettyDate(null)).toBeUndefined();
 | 
			
		||||
        expect(prettyDate('my birthday')).toBeUndefined();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    test('past dates',() => {
 | 
			
		||||
        expect(pd({seconds: -5})).toBe('just now');
 | 
			
		||||
        expect(pd({minutes: -5})).toBe('5m ago')
 | 
			
		||||
        expect(pd({days: -7})).toBe('last Tuesday')
 | 
			
		||||
        expect(pd({days: -8})).toBe('1 week ago')
 | 
			
		||||
        expect(pd({days: -14})).toBe('2 weeks ago')
 | 
			
		||||
        expect(pd({days: -31})).toBe('8 Oct')
 | 
			
		||||
        expect(pd({days: -(31 + 366)})).toBe('8 Oct 2015')
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    test('past dates with time',() => {
 | 
			
		||||
        expect(pd({seconds: -5, detailed: true})).toBe('just now');
 | 
			
		||||
        expect(pd({minutes: -5, detailed: true})).toBe('5m ago')
 | 
			
		||||
        expect(pd({days: -7, detailed: true})).toBe('last Tuesday at 11:46')
 | 
			
		||||
        expect(pd({days: -8, detailed: true})).toBe('1 week ago at 11:46')
 | 
			
		||||
        // summer time bellow
 | 
			
		||||
        expect(pd({days: -14, detailed: true})).toBe('2 weeks ago at 10:46')
 | 
			
		||||
        expect(pd({days: -31, detailed: true})).toBe('8 Oct at 10:46')
 | 
			
		||||
        expect(pd({days: -(31 + 366), detailed: true})).toBe('8 Oct 2015 at 10:46')
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    test('future dates',() => {
 | 
			
		||||
        expect(pd({seconds: 5})).toBe('just now')
 | 
			
		||||
        expect(pd({minutes: 5})).toBe('in 5m')
 | 
			
		||||
        expect(pd({days: 7})).toBe('next Tuesday')
 | 
			
		||||
        expect(pd({days: 8})).toBe('in 1 week')
 | 
			
		||||
        expect(pd({days: 14})).toBe('in 2 weeks')
 | 
			
		||||
        expect(pd({days: 30})).toBe('8 Dec')
 | 
			
		||||
        expect(pd({days: 30 + 365})).toBe('8 Dec 2017')
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    test('future dates',() => {
 | 
			
		||||
        expect(pd({seconds: 5, detailed: true})).toBe('just now')
 | 
			
		||||
        expect(pd({minutes: 5, detailed: true})).toBe('in 5m')
 | 
			
		||||
        expect(pd({days: 7, detailed: true})).toBe('next Tuesday at 11:46')
 | 
			
		||||
        expect(pd({days: 8, detailed: true})).toBe('in 1 week at 11:46')
 | 
			
		||||
        expect(pd({days: 14, detailed: true})).toBe('in 2 weeks at 11:46')
 | 
			
		||||
        expect(pd({days: 30, detailed: true})).toBe('8 Dec at 11:46')
 | 
			
		||||
        expect(pd({days: 30 + 365, detailed: true})).toBe('8 Dec 2017 at 11:46')
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    function pd(params) {
 | 
			
		||||
        let theDate = new Date(Date.now());
 | 
			
		||||
        theDate.setFullYear(theDate.getFullYear() + (params['years'] || 0));
 | 
			
		||||
        theDate.setMonth(theDate.getMonth() + (params['months'] || 0));
 | 
			
		||||
        theDate.setDate(theDate.getDate() + (params['days'] || 0));
 | 
			
		||||
        theDate.setHours(theDate.getHours() + (params['hours'] || 0));
 | 
			
		||||
        theDate.setMinutes(theDate.getMinutes() + (params['minutes'] || 0));
 | 
			
		||||
        theDate.setSeconds(theDate.getSeconds() + (params['seconds'] || 0));
 | 
			
		||||
        return prettyDate(theDate, (params['detailed'] || false))
 | 
			
		||||
    }
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										34
									
								
								src/scripts/js/es6/common/templates/component/Component.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/scripts/js/es6/common/templates/component/Component.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,34 @@
 | 
			
		||||
import { ComponentCreatorInterface } from './ComponentCreatorInterface'
 | 
			
		||||
 | 
			
		||||
const REGISTERED_CREATORS = []
 | 
			
		||||
 | 
			
		||||
export class Component extends ComponentCreatorInterface {
 | 
			
		||||
    static create$listItem(doc) {
 | 
			
		||||
        let creator = Component.getCreator(doc);
 | 
			
		||||
        return creator.create$listItem(doc);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    static create$item(doc) {
 | 
			
		||||
        let creator = Component.getCreator(doc);
 | 
			
		||||
        return creator.create$item(doc);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    static canCreate(candidate) {
 | 
			
		||||
        return !!Component.getCreator(candidate);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    static regiseterCreator(creator) {
 | 
			
		||||
        REGISTERED_CREATORS.push(creator);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    static getCreator(doc) {
 | 
			
		||||
        if (doc) {
 | 
			
		||||
            for (let candidate of REGISTERED_CREATORS) {
 | 
			
		||||
                if (candidate.canCreate(doc)) {
 | 
			
		||||
                    return candidate;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        throw 'Can not create component using: ' + JSON.stringify(doc);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,27 @@
 | 
			
		||||
export class ComponentCreatorInterface {
 | 
			
		||||
    /**
 | 
			
		||||
     * @param {JSON} doc 
 | 
			
		||||
     * @returns {$element}
 | 
			
		||||
     */
 | 
			
		||||
    static create$listItem(doc) {
 | 
			
		||||
        throw 'Not Implemented';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @param {JSON} doc 
 | 
			
		||||
     * @returns {$element}
 | 
			
		||||
     */
 | 
			
		||||
    static create$item(doc) {
 | 
			
		||||
        throw 'Not Implemented';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @param {JSON} candidate
 | 
			
		||||
     * @returns {boolean} 
 | 
			
		||||
     */
 | 
			
		||||
    static canCreate(candidate) {
 | 
			
		||||
        throw 'Not Implemented';
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										18
									
								
								src/scripts/js/es6/common/templates/init.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								src/scripts/js/es6/common/templates/init.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
			
		||||
import { Nodes } from './nodes/Nodes';
 | 
			
		||||
import { Assets } from './nodes/Assets';
 | 
			
		||||
import { Posts } from './nodes/Posts';
 | 
			
		||||
 | 
			
		||||
import { Users } from './users/Users';
 | 
			
		||||
import { Component } from './component/Component';
 | 
			
		||||
 | 
			
		||||
Nodes.registerTemplate('asset', Assets);
 | 
			
		||||
Nodes.registerTemplate('post', Posts);
 | 
			
		||||
 | 
			
		||||
Component.regiseterCreator(Nodes);
 | 
			
		||||
Component.regiseterCreator(Users);
 | 
			
		||||
 | 
			
		||||
export {
 | 
			
		||||
    Nodes,
 | 
			
		||||
    Users,
 | 
			
		||||
    Component
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										45
									
								
								src/scripts/js/es6/common/templates/nodes/Assets.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								src/scripts/js/es6/common/templates/nodes/Assets.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,45 @@
 | 
			
		||||
import { NodesBase } from "./NodesBase";
 | 
			
		||||
import { thenLoadVideoProgress } from '../utils';
 | 
			
		||||
 | 
			
		||||
export class Assets extends NodesBase{
 | 
			
		||||
    static create$listItem(node) {
 | 
			
		||||
        var markIfPublic = true;
 | 
			
		||||
        let $card = super.create$listItem(node);
 | 
			
		||||
        $card.addClass('asset');
 | 
			
		||||
 | 
			
		||||
        if (node.properties && node.properties.duration){
 | 
			
		||||
            let $thumbnailContainer = $card.find('.js-thumbnail-container')
 | 
			
		||||
            let $cardDuration = $('<div class="card-label right">' + node.properties.duration + '</div>');
 | 
			
		||||
            $thumbnailContainer.append($cardDuration);
 | 
			
		||||
 | 
			
		||||
            /* Video progress and 'watched' label. */
 | 
			
		||||
            $(window).trigger('pillar:workStart');
 | 
			
		||||
            thenLoadVideoProgress(node._id)
 | 
			
		||||
                .fail(console.log)
 | 
			
		||||
                .then((view_progress)=>{
 | 
			
		||||
                    if (!view_progress) return
 | 
			
		||||
 | 
			
		||||
                    let $cardProgress = $('<div class="progress rounded-0">');
 | 
			
		||||
                    let $cardProgressBar = $('<div class="progress-bar">');
 | 
			
		||||
                    $cardProgressBar.css('width', view_progress.progress_in_percent + '%');
 | 
			
		||||
                    $cardProgress.append($cardProgressBar);
 | 
			
		||||
                    $thumbnailContainer.append($cardProgress);
 | 
			
		||||
 | 
			
		||||
                    if (view_progress.done){
 | 
			
		||||
                        let card_progress_done = $('<div class="card-label">WATCHED</div>');
 | 
			
		||||
                        $thumbnailContainer.append(card_progress_done);
 | 
			
		||||
                    }
 | 
			
		||||
                })
 | 
			
		||||
                .always(function() {
 | 
			
		||||
                    $(window).trigger('pillar:workStop');
 | 
			
		||||
                });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        /* 'Free' ribbon for public assets. */
 | 
			
		||||
        if (markIfPublic && node.permissions && node.permissions.world){
 | 
			
		||||
            $card.addClass('free');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $card;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										63
									
								
								src/scripts/js/es6/common/templates/nodes/Nodes.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								src/scripts/js/es6/common/templates/nodes/Nodes.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,63 @@
 | 
			
		||||
import { NodesBase } from './NodesBase';
 | 
			
		||||
import { ComponentCreatorInterface } from '../component/ComponentCreatorInterface'
 | 
			
		||||
 | 
			
		||||
let CREATE_NODE_ITEM_MAP = {}
 | 
			
		||||
 | 
			
		||||
export class Nodes extends ComponentCreatorInterface {
 | 
			
		||||
    /**
 | 
			
		||||
     * Creates a small list item out of a node document
 | 
			
		||||
     * @param {NodeDoc} node mongodb or elastic node document
 | 
			
		||||
     */
 | 
			
		||||
    static create$listItem(node) {
 | 
			
		||||
        let factory = CREATE_NODE_ITEM_MAP[node.node_type] || NodesBase;
 | 
			
		||||
        return factory.create$listItem(node);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Creates a full view out of a node document
 | 
			
		||||
     * @param {NodeDoc} node mongodb or elastic node document
 | 
			
		||||
     */
 | 
			
		||||
    static create$item(node) {
 | 
			
		||||
        let factory = CREATE_NODE_ITEM_MAP[node.node_type] || NodesBase;
 | 
			
		||||
        return factory.create$item(node);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Creates a list of items and a 'Load More' button
 | 
			
		||||
     * @param {List} nodes A list of nodes to be created
 | 
			
		||||
     * @param {Int} initial Number of nodes to show initially
 | 
			
		||||
     * @param {Int} loadNext Number of nodes to show when clicking 'Load More'. If 0, no load more button will be shown
 | 
			
		||||
     */
 | 
			
		||||
    static createListOf$nodeItems(nodes, initial=8, loadNext=8) {
 | 
			
		||||
        let nodesLeftToRender = nodes.slice();
 | 
			
		||||
        let nodesToCreate = nodesLeftToRender.splice(0, initial);
 | 
			
		||||
        let listOf$items = nodesToCreate.map(Nodes.create$listItem);
 | 
			
		||||
 | 
			
		||||
        if (loadNext > 0 && nodesLeftToRender.length) {
 | 
			
		||||
            let $link = $('<a>')
 | 
			
		||||
                .addClass('btn btn-outline-primary px-5 mb-auto btn-block js-load-next')
 | 
			
		||||
                .attr('href', 'javascript:void(0);')
 | 
			
		||||
                .click((e)=> { 
 | 
			
		||||
                    let $target = $(e.target);
 | 
			
		||||
                    $target.replaceWith(Nodes.createListOf$nodeItems(nodesLeftToRender, loadNext, loadNext));
 | 
			
		||||
                 })
 | 
			
		||||
                .text('Load More');
 | 
			
		||||
 | 
			
		||||
            listOf$items.push($link);
 | 
			
		||||
        }
 | 
			
		||||
        return listOf$items;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    static canCreate(candidate) {
 | 
			
		||||
        return !!candidate.node_type;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Register template classes to handle the cunstruction of diffrent node types
 | 
			
		||||
     * @param { String } node_type The node type whose template that is registered
 | 
			
		||||
     * @param { NodesBase } klass The class to handle the creation of jQuery objects
 | 
			
		||||
     */
 | 
			
		||||
    static registerTemplate(node_type, klass) {
 | 
			
		||||
        CREATE_NODE_ITEM_MAP[node_type] = klass;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										58
									
								
								src/scripts/js/es6/common/templates/nodes/NodesBase.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								src/scripts/js/es6/common/templates/nodes/NodesBase.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,58 @@
 | 
			
		||||
import { thenLoadImage, prettyDate } from '../utils';
 | 
			
		||||
import { ComponentCreatorInterface } from '../component/ComponentCreatorInterface'
 | 
			
		||||
 | 
			
		||||
export class NodesBase extends ComponentCreatorInterface {
 | 
			
		||||
    static create$listItem(node) {
 | 
			
		||||
        let nid = (node._id || node.objectID); // To support both mongo and elastic nodes
 | 
			
		||||
        let $card = $('<a class="card node card-image-fade asset">')
 | 
			
		||||
            .attr('data-node-id', nid)
 | 
			
		||||
            .attr('href', '/nodes/' + nid + '/redir')
 | 
			
		||||
            .attr('title', node.name);
 | 
			
		||||
        let $thumbnailContainer = $('<div class="card-thumbnail js-thumbnail-container">');
 | 
			
		||||
        function warnNoPicture() {
 | 
			
		||||
            let $cardIcon = $('<div class="card-img-top card-icon">');
 | 
			
		||||
            $cardIcon.html('<i class="pi-' + node.node_type + '">');
 | 
			
		||||
            $thumbnailContainer.append($cardIcon);
 | 
			
		||||
        }
 | 
			
		||||
        if (!node.picture) {
 | 
			
		||||
            warnNoPicture();
 | 
			
		||||
        }
 | 
			
		||||
        else {
 | 
			
		||||
            $(window).trigger('pillar:workStart');
 | 
			
		||||
            thenLoadImage(node.picture)
 | 
			
		||||
                .fail(warnNoPicture)
 | 
			
		||||
                .then((imgVariation) => {
 | 
			
		||||
                    let img = $('<img class="card-img-top">')
 | 
			
		||||
                        .attr('alt', node.name)
 | 
			
		||||
                        .attr('src', imgVariation.link)
 | 
			
		||||
                        .attr('width', imgVariation.width)
 | 
			
		||||
                        .attr('height', imgVariation.height);
 | 
			
		||||
                    $thumbnailContainer.append(img);
 | 
			
		||||
                })
 | 
			
		||||
                .always(function () {
 | 
			
		||||
                    $(window).trigger('pillar:workStop');
 | 
			
		||||
                });
 | 
			
		||||
        }
 | 
			
		||||
        $card.append($thumbnailContainer);
 | 
			
		||||
        /* Card body for title and meta info. */
 | 
			
		||||
        let $cardBody = $('<div class="card-body p-2 d-flex flex-column">');
 | 
			
		||||
        let $cardTitle = $('<div class="card-title px-2 mb-2 font-weight-bold">');
 | 
			
		||||
        $cardTitle.text(node.name);
 | 
			
		||||
        $cardBody.append($cardTitle);
 | 
			
		||||
        let $cardMeta = $('<ul class="card-text px-2 list-unstyled d-flex text-black-50 mt-auto">');
 | 
			
		||||
        let $cardProject = $('<a class="font-weight-bold pr-2">')
 | 
			
		||||
            .attr('href', '/p/' + node.project.url)
 | 
			
		||||
            .attr('title', node.project.name)
 | 
			
		||||
            .text(node.project.name);
 | 
			
		||||
        $cardMeta.append($cardProject);
 | 
			
		||||
        let created = node._created || node.created_at; // mongodb + elastic
 | 
			
		||||
        $cardMeta.append('<li>' + prettyDate(created) + '</li>');
 | 
			
		||||
        $cardBody.append($cardMeta);
 | 
			
		||||
        $card.append($cardBody);
 | 
			
		||||
        return $card;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    static canCreate(candidate) {
 | 
			
		||||
        return !!candidate.node_type;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										21
									
								
								src/scripts/js/es6/common/templates/nodes/Posts.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/scripts/js/es6/common/templates/nodes/Posts.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
			
		||||
import { NodesBase } from "./NodesBase";
 | 
			
		||||
 | 
			
		||||
export class Posts extends NodesBase {
 | 
			
		||||
    static create$item(post) {
 | 
			
		||||
        let content = [];
 | 
			
		||||
        let $title = $('<div>')
 | 
			
		||||
            .addClass('h1 text-uppercase mt-4 mb-3')
 | 
			
		||||
            .text(post.name);
 | 
			
		||||
        content.push($title);
 | 
			
		||||
        let $post = $('<div>')
 | 
			
		||||
                .addClass('expand-image-links imgs-fluid')
 | 
			
		||||
                .append(
 | 
			
		||||
                    content,
 | 
			
		||||
                    $('<div>')
 | 
			
		||||
                        .addClass('node-details-description')
 | 
			
		||||
                        .html(post['properties']['pretty_content'])
 | 
			
		||||
                );
 | 
			
		||||
 | 
			
		||||
        return $post;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										23
									
								
								src/scripts/js/es6/common/templates/users/Users.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/scripts/js/es6/common/templates/users/Users.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
			
		||||
import { ComponentCreatorInterface } from '../component/ComponentCreatorInterface'
 | 
			
		||||
 | 
			
		||||
export class Users extends ComponentCreatorInterface {
 | 
			
		||||
    static create$listItem(userDoc) {
 | 
			
		||||
        return $('<div>')
 | 
			
		||||
            .addClass('users p-2 border-bottom')
 | 
			
		||||
            .attr('data-user-id', userDoc._id || userDoc.objectID )
 | 
			
		||||
            .append(
 | 
			
		||||
                $('<h6>')
 | 
			
		||||
                    .addClass('mb-0 font-weight-bold')
 | 
			
		||||
                    .text(userDoc.full_name),
 | 
			
		||||
                $('<small>')
 | 
			
		||||
                    .text(userDoc.username),
 | 
			
		||||
                $('<small>')
 | 
			
		||||
                    .addClass('d-block roles text-info')
 | 
			
		||||
                    .text(userDoc.roles.join(', '))
 | 
			
		||||
            )
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    static canCreate(candidate) {
 | 
			
		||||
        return !!candidate.username;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,46 @@
 | 
			
		||||
import { Users } from '../Users'
 | 
			
		||||
 | 
			
		||||
describe('Users', () => {
 | 
			
		||||
    let userDoc;
 | 
			
		||||
    describe('create$listItem', () => {
 | 
			
		||||
        beforeEach(()=>{
 | 
			
		||||
            userDoc = {
 | 
			
		||||
                _id: 'my-user-id',
 | 
			
		||||
                username: 'My User Name',
 | 
			
		||||
                full_name: 'My full name',
 | 
			
		||||
                roles: ['admin', 'subscriber']
 | 
			
		||||
            };
 | 
			
		||||
        });
 | 
			
		||||
        test('happy case', () => {
 | 
			
		||||
            let $user = Users.create$listItem(userDoc);
 | 
			
		||||
            expect($user.length).toBe(1);
 | 
			
		||||
            expect($user.hasClass('users')).toBeTruthy();
 | 
			
		||||
            expect($user.data('user-id')).toBe('my-user-id');
 | 
			
		||||
 | 
			
		||||
            let $username = $user.find(':contains(My User Name)');
 | 
			
		||||
            expect($username.length).toBe(1);
 | 
			
		||||
 | 
			
		||||
            let $fullName = $user.find(':contains(My full name)');
 | 
			
		||||
            expect($fullName.length).toBe(1);
 | 
			
		||||
 | 
			
		||||
            let $roles = $user.find('.roles');
 | 
			
		||||
            expect($roles.length).toBe(1);
 | 
			
		||||
            expect($roles.text()).toBe('admin, subscriber')
 | 
			
		||||
        });
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    describe('create$item', () => {
 | 
			
		||||
        beforeEach(()=>{
 | 
			
		||||
            userDoc = {
 | 
			
		||||
                _id: 'my-user-id',
 | 
			
		||||
                username: 'My User Name',
 | 
			
		||||
                full_name: 'My full name',
 | 
			
		||||
                roles: ['admin', 'subscriber']
 | 
			
		||||
            };
 | 
			
		||||
        });
 | 
			
		||||
        test('Not Implemented', () => {
 | 
			
		||||
            // Replace with proper test once implemented
 | 
			
		||||
            expect(()=>Users.create$item(userDoc)).toThrow('Not Implemented');
 | 
			
		||||
        });
 | 
			
		||||
    })
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										122
									
								
								src/scripts/js/es6/common/templates/utils.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										122
									
								
								src/scripts/js/es6/common/templates/utils.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,122 @@
 | 
			
		||||
function thenLoadImage(imgId, size = 'm') {
 | 
			
		||||
    return $.get('/api/files/' + imgId)
 | 
			
		||||
            .then((resp)=> {
 | 
			
		||||
                var show_variation = null;
 | 
			
		||||
                if (typeof resp.variations != 'undefined') {
 | 
			
		||||
                    for (var variation of resp.variations) {
 | 
			
		||||
                        if (variation.size != size) continue;
 | 
			
		||||
                        show_variation = variation;
 | 
			
		||||
                        break;
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if (show_variation == null) {
 | 
			
		||||
                    throw 'Image not found: ' + imgId + ' size: ' + size;
 | 
			
		||||
                }
 | 
			
		||||
                return show_variation;
 | 
			
		||||
            })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function thenLoadVideoProgress(nodeId) {
 | 
			
		||||
    return $.get('/api/users/video/' + nodeId + '/progress')
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function prettyDate(time, detail=false) {
 | 
			
		||||
    /**
 | 
			
		||||
     * time is anything Date can parse, and we return a
 | 
			
		||||
    pretty string like 'an hour ago', 'Yesterday', '3 months ago',
 | 
			
		||||
    'just now', etc
 | 
			
		||||
     */
 | 
			
		||||
    let theDate = new Date(time);
 | 
			
		||||
    if (!time || isNaN(theDate)) {
 | 
			
		||||
        return
 | 
			
		||||
    }
 | 
			
		||||
    let pretty = '';
 | 
			
		||||
    let now = new Date(Date.now()); // Easier to mock Date.now() in tests
 | 
			
		||||
    let second_diff = Math.round((now - theDate) / 1000);
 | 
			
		||||
    
 | 
			
		||||
    let day_diff = Math.round(second_diff / 86400); // seconds per day (60*60*24)
 | 
			
		||||
 | 
			
		||||
    if ((day_diff < 0) && (theDate.getFullYear() !== now.getFullYear())) {
 | 
			
		||||
        // "Jul 16, 2018"
 | 
			
		||||
        pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short', year: 'numeric'});
 | 
			
		||||
    }
 | 
			
		||||
    else if ((day_diff < -21) && (theDate.getFullYear() == now.getFullYear())) {
 | 
			
		||||
        // "Jul 16"
 | 
			
		||||
        pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short'});
 | 
			
		||||
    }
 | 
			
		||||
    else if (day_diff < -7){
 | 
			
		||||
        let week_count = Math.round(-day_diff / 7);
 | 
			
		||||
        if (week_count == 1)
 | 
			
		||||
            pretty = "in 1 week";
 | 
			
		||||
        else
 | 
			
		||||
            pretty = "in " + week_count +" weeks";
 | 
			
		||||
    }
 | 
			
		||||
    else if (day_diff < -1)
 | 
			
		||||
        // "next Tuesday"
 | 
			
		||||
        pretty = 'next ' + theDate.toLocaleDateString('en-NL',{weekday: 'long'});
 | 
			
		||||
    else if (day_diff === 0) {
 | 
			
		||||
        if (second_diff < 0) {
 | 
			
		||||
            let seconds = Math.abs(second_diff);
 | 
			
		||||
            if (seconds < 10)
 | 
			
		||||
                return 'just now';
 | 
			
		||||
            if (seconds < 60)
 | 
			
		||||
                return 'in ' + seconds +'s';
 | 
			
		||||
            if (seconds < 120)
 | 
			
		||||
                return 'in a minute';
 | 
			
		||||
            if (seconds < 3600)
 | 
			
		||||
                return 'in ' + Math.round(seconds / 60) + 'm';
 | 
			
		||||
            if (seconds < 7200)
 | 
			
		||||
                return 'in an hour';
 | 
			
		||||
            if (seconds < 86400)
 | 
			
		||||
                return 'in ' + Math.round(seconds / 3600) + 'h';
 | 
			
		||||
        } else {
 | 
			
		||||
            let seconds = second_diff;
 | 
			
		||||
            if (seconds < 10)
 | 
			
		||||
                return "just now";
 | 
			
		||||
            if (seconds < 60)
 | 
			
		||||
                return seconds + "s ago";
 | 
			
		||||
            if (seconds < 120)
 | 
			
		||||
                return "a minute ago";
 | 
			
		||||
            if (seconds < 3600)
 | 
			
		||||
                return Math.round(seconds / 60) + "m ago";
 | 
			
		||||
            if (seconds < 7200)
 | 
			
		||||
                return "an hour ago";
 | 
			
		||||
            if (seconds < 86400)
 | 
			
		||||
                return Math.round(seconds / 3600) + "h ago";
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
    }
 | 
			
		||||
    else if (day_diff == 1)
 | 
			
		||||
        pretty = "yesterday";
 | 
			
		||||
 | 
			
		||||
    else if (day_diff <= 7)
 | 
			
		||||
        // "last Tuesday"
 | 
			
		||||
        pretty = 'last ' + theDate.toLocaleDateString('en-NL',{weekday: 'long'});
 | 
			
		||||
 | 
			
		||||
    else if (day_diff <= 22) {
 | 
			
		||||
        let week_count = Math.round(day_diff / 7);
 | 
			
		||||
        if (week_count == 1)
 | 
			
		||||
            pretty = "1 week ago";
 | 
			
		||||
        else
 | 
			
		||||
            pretty = week_count + " weeks ago";
 | 
			
		||||
    }
 | 
			
		||||
    else if (theDate.getFullYear() === now.getFullYear())
 | 
			
		||||
        // "Jul 16"
 | 
			
		||||
        pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short'});
 | 
			
		||||
 | 
			
		||||
    else
 | 
			
		||||
        // "Jul 16", 2009
 | 
			
		||||
        pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short', year: 'numeric'});
 | 
			
		||||
 | 
			
		||||
    if (detail){
 | 
			
		||||
        // "Tuesday at 04:20"
 | 
			
		||||
        let paddedHour = ('00' + theDate.getUTCHours()).substr(-2);
 | 
			
		||||
        let paddedMin = ('00' + theDate.getUTCMinutes()).substr(-2);
 | 
			
		||||
        return pretty + ' at '  + paddedHour + ':' + paddedMin;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return pretty;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { thenLoadImage, thenLoadVideoProgress, prettyDate };
 | 
			
		||||
							
								
								
									
										1
									
								
								src/scripts/js/es6/common/utils/init.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/scripts/js/es6/common/utils/init.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
export { transformPlaceholder } from './placeholder'
 | 
			
		||||
							
								
								
									
										15
									
								
								src/scripts/js/es6/common/utils/placeholder.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/scripts/js/es6/common/utils/placeholder.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,15 @@
 | 
			
		||||
/**
 | 
			
		||||
 * Fade out placeholder, then call callback.
 | 
			
		||||
 * Note that the placeholder will not be removed, and will not be keeped hidden. The caller decides what to do with 
 | 
			
		||||
 * the placeholder.
 | 
			
		||||
 * @param {jQueryObject} $placeholder 
 | 
			
		||||
 * @param {callback} cb 
 | 
			
		||||
 */
 | 
			
		||||
export function transformPlaceholder($placeholder, cb) {
 | 
			
		||||
    $placeholder.addClass('placeholder replaced')
 | 
			
		||||
        .delay(250)
 | 
			
		||||
        .queue(()=>{
 | 
			
		||||
            $placeholder.removeClass('placeholder replaced');
 | 
			
		||||
            cb();
 | 
			
		||||
        })
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										198
									
								
								src/scripts/js/es6/individual/timeline/Timeline.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										198
									
								
								src/scripts/js/es6/individual/timeline/Timeline.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,198 @@
 | 
			
		||||
/**
 | 
			
		||||
 * Consumes data in the form:
 | 
			
		||||
 * {
 | 
			
		||||
 *  groups: [{
 | 
			
		||||
 *      label: 'Week 32',
 | 
			
		||||
 *      url: null, // optional
 | 
			
		||||
 *      groups: [{
 | 
			
		||||
 *          label: 'Spring',
 | 
			
		||||
 *          url: '/p/spring',
 | 
			
		||||
 *          items:{
 | 
			
		||||
 *              post: [nodeDoc, nodeDoc],  // primary (fully rendered)
 | 
			
		||||
 *              asset: [nodeDoc, nodeDoc]   // secondary (rendered as list item)
 | 
			
		||||
 *          },
 | 
			
		||||
 *          groups: ...
 | 
			
		||||
 *      }]
 | 
			
		||||
 * }],
 | 
			
		||||
 *  continue_from: 123456.2 // python timestamp
 | 
			
		||||
 * }
 | 
			
		||||
 */
 | 
			
		||||
const DEFAULT_URL = '/api/timeline';
 | 
			
		||||
const transformPlaceholder = pillar.utils.transformPlaceholder;
 | 
			
		||||
 | 
			
		||||
export class Timeline {
 | 
			
		||||
    constructor(target, builder) {
 | 
			
		||||
        this._$targetDom = $(target);
 | 
			
		||||
        this._url;
 | 
			
		||||
        this._queryParams = {};
 | 
			
		||||
        this._builder = builder;
 | 
			
		||||
        this._init();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _init() {
 | 
			
		||||
        this._workStart();
 | 
			
		||||
        this._setUrl();
 | 
			
		||||
        this._setQueryParams();
 | 
			
		||||
        this._thenLoadMore()
 | 
			
		||||
            .then((it)=>{
 | 
			
		||||
                transformPlaceholder(this._$targetDom, () => {
 | 
			
		||||
                    this._$targetDom.empty()
 | 
			
		||||
                            .append(it);
 | 
			
		||||
                        if (this._hasMore()) {
 | 
			
		||||
                            let btn = this._create$LoadMoreBtn();
 | 
			
		||||
                            this._$targetDom.append(btn);
 | 
			
		||||
                        }
 | 
			
		||||
                })
 | 
			
		||||
            })
 | 
			
		||||
            .always(this._workStop.bind(this));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _setUrl() {
 | 
			
		||||
        let projectId = this._$targetDom.data('project-id');
 | 
			
		||||
        this._url = DEFAULT_URL
 | 
			
		||||
        if (projectId) {
 | 
			
		||||
            this._url += '/p/' + projectId
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _setQueryParams() {
 | 
			
		||||
        let sortDirection = this._$targetDom.data('sort-dir');
 | 
			
		||||
        if (sortDirection) {
 | 
			
		||||
            this._queryParams['dir'] = sortDirection;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _loadMore(event) {
 | 
			
		||||
        let $spinner = $('<i>').addClass('ml-2 pi-spin spinner');
 | 
			
		||||
        let $loadmoreBtn = $(event.target)
 | 
			
		||||
            .append($spinner)
 | 
			
		||||
            .addClass('disabled');
 | 
			
		||||
 | 
			
		||||
        this._workStart();
 | 
			
		||||
        this._thenLoadMore()
 | 
			
		||||
            .then((it)=>{
 | 
			
		||||
                $loadmoreBtn.before(it);
 | 
			
		||||
            })
 | 
			
		||||
            .always(()=>{
 | 
			
		||||
                if (this._hasMore()) {
 | 
			
		||||
                    $loadmoreBtn.removeClass('disabled');
 | 
			
		||||
                    $spinner.remove();
 | 
			
		||||
                } else {
 | 
			
		||||
                    $loadmoreBtn.remove();
 | 
			
		||||
                }
 | 
			
		||||
                this._workStop();
 | 
			
		||||
            });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _hasMore() {
 | 
			
		||||
        return !!this._queryParams['from'];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _thenLoadMore() {
 | 
			
		||||
        this._workStart();
 | 
			
		||||
        let qParams = $.param(this._queryParams);
 | 
			
		||||
        return $.getJSON(this._url + '?' + qParams)
 | 
			
		||||
            .then(this._render.bind(this))
 | 
			
		||||
            .fail(this._workFailed.bind(this))
 | 
			
		||||
            .always(this._workStop.bind(this))
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _render(toRender) {
 | 
			
		||||
        this._queryParams['from'] = toRender['continue_from'];
 | 
			
		||||
        return toRender['groups']
 | 
			
		||||
            .map(this._create$Group.bind(this));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _create$Group(group) {
 | 
			
		||||
        return this._builder.build$Group(0, group);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _create$LoadMoreBtn() {
 | 
			
		||||
        return $('<a>')
 | 
			
		||||
            .addClass('btn btn-outline-primary btn-block js-load-next mb-3')
 | 
			
		||||
            .attr('href', 'javascript:void(0);')
 | 
			
		||||
            .click(this._loadMore.bind(this))
 | 
			
		||||
            .text('Show More Weeks');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _workStart() {
 | 
			
		||||
        this._$targetDom.trigger('pillar:workStart');
 | 
			
		||||
        return arguments;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _workStop() {
 | 
			
		||||
        this._$targetDom.trigger('pillar:workStop');
 | 
			
		||||
        return arguments;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _workFailed(error) {
 | 
			
		||||
        let msg = xhrErrorResponseMessage(error);
 | 
			
		||||
        this._$targetDom.trigger('pillar:failure', msg);
 | 
			
		||||
        return error;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class GroupBuilder {
 | 
			
		||||
    build$Group(level, group) {
 | 
			
		||||
        let content = []
 | 
			
		||||
        let $label = this._create$Label(level, group['label'], group['url']);
 | 
			
		||||
        if (group['items']) {
 | 
			
		||||
            content = content.concat(this._create$Items(group['items']));
 | 
			
		||||
        }
 | 
			
		||||
        if(group['groups']) {
 | 
			
		||||
            content = content.concat(group['groups'].map(this.build$Group.bind(this, level+1)));
 | 
			
		||||
        }
 | 
			
		||||
        return $('<div>')
 | 
			
		||||
            .addClass('group')
 | 
			
		||||
            .append(
 | 
			
		||||
                $label,
 | 
			
		||||
                content
 | 
			
		||||
            );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _create$Items(items) {
 | 
			
		||||
        let content = [];
 | 
			
		||||
        let primaryNodes = items['post'];
 | 
			
		||||
        let secondaryNodes = items['asset'];
 | 
			
		||||
        if (primaryNodes) {
 | 
			
		||||
            content.push(
 | 
			
		||||
                $('<div>')
 | 
			
		||||
                    .append(primaryNodes.map(pillar.templates.Nodes.create$item))
 | 
			
		||||
            );
 | 
			
		||||
        }
 | 
			
		||||
        if (secondaryNodes) {
 | 
			
		||||
            content.push(
 | 
			
		||||
                $('<div>')
 | 
			
		||||
                    .addClass('card-deck card-padless card-deck-responsive js-asset-list p-3 pb-5 mb-5')
 | 
			
		||||
                    .append(pillar.templates.Nodes.createListOf$nodeItems(secondaryNodes))
 | 
			
		||||
            );
 | 
			
		||||
        }
 | 
			
		||||
        return content;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _create$Label(level, label, url) {
 | 
			
		||||
        let type = level == 0 ? 'h6 float-right py-2 group-date' : 'h6 py-2 group-title'
 | 
			
		||||
        if (url) {
 | 
			
		||||
            return $('<div>')
 | 
			
		||||
                .addClass(type + ' sticky-top')
 | 
			
		||||
                .append(
 | 
			
		||||
                    $('<a>')
 | 
			
		||||
                        .attr('href', url)
 | 
			
		||||
                        .text(label)
 | 
			
		||||
            );
 | 
			
		||||
        }
 | 
			
		||||
        return $('<div>')
 | 
			
		||||
            .addClass(type + ' sticky-top')
 | 
			
		||||
            .text(label);
 | 
			
		||||
    }
 | 
			
		||||
 }
 | 
			
		||||
 | 
			
		||||
$.fn.extend({
 | 
			
		||||
    timeline: function() {
 | 
			
		||||
        this.each(function(i, target) {
 | 
			
		||||
            new Timeline(target,
 | 
			
		||||
                new GroupBuilder()
 | 
			
		||||
            );
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
})
 | 
			
		||||
							
								
								
									
										7
									
								
								src/scripts/js/es6/individual/timeline/init.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/scripts/js/es6/individual/timeline/init.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
			
		||||
export { Timeline } from './Timeline';
 | 
			
		||||
 | 
			
		||||
// Init timelines on document ready
 | 
			
		||||
$(function() {
 | 
			
		||||
    $(".timeline")
 | 
			
		||||
		  .timeline();
 | 
			
		||||
})
 | 
			
		||||
							
								
								
									
										2
									
								
								src/scripts/js/es6/test_config/test-env.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								src/scripts/js/es6/test_config/test-env.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,2 @@
 | 
			
		||||
import $ from 'jquery';
 | 
			
		||||
global.$ = global.jQuery = $;
 | 
			
		||||
@@ -98,3 +98,104 @@ function statusBarSet(classes, html, icon_name, time){
 | 
			
		||||
	statusBarClear(time, 250);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/* Loading Bar
 | 
			
		||||
 * Sets .loader-bar in layout.pug as active when
 | 
			
		||||
 * loading an asset or performing actions.
 | 
			
		||||
 */
 | 
			
		||||
function loadingBarShow(){
 | 
			
		||||
	/* NEVER call this directly! Trigger pillar:workStart event instead */
 | 
			
		||||
	$('.loading-bar').addClass('active');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function loadingBarHide(){
 | 
			
		||||
	/* NEVER call this directly! Trigger pillar:workStop event instead */
 | 
			
		||||
	$('.loading-bar').removeClass('active');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
(function loadingbar () {
 | 
			
		||||
	var busyCounter = 0;
 | 
			
		||||
 | 
			
		||||
	$(window)
 | 
			
		||||
		.on('pillar:workStart', function(e) {
 | 
			
		||||
			busyCounter += 1;
 | 
			
		||||
			loadingBarShow();
 | 
			
		||||
		})
 | 
			
		||||
		.on('pillar:workStop', function() {
 | 
			
		||||
			busyCounter -= 1;
 | 
			
		||||
			if(busyCounter === 0) {
 | 
			
		||||
				loadingBarHide();
 | 
			
		||||
			}
 | 
			
		||||
		})
 | 
			
		||||
})();
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
$(document).ready(function() {
 | 
			
		||||
 | 
			
		||||
	/* Mobile check. */
 | 
			
		||||
	var isMobileScreen = window.matchMedia("only screen and (max-width: 760px)");
 | 
			
		||||
 | 
			
		||||
	function isMobile(){
 | 
			
		||||
		return isMobileScreen.matches;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Every element that is a tab.
 | 
			
		||||
	$dropdownTabs = $('[data-dropdown-tab]');
 | 
			
		||||
	// Every menu element that toggles a tab.
 | 
			
		||||
	$dropdownTabsToggle = $('[data-toggle="dropdown-tab"]');
 | 
			
		||||
 | 
			
		||||
	function dropdownTabHideAll(){
 | 
			
		||||
		$dropdownTabs.removeClass('show');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	function dropdownTabShow(tab){
 | 
			
		||||
		dropdownTabHideAll(); // First hide them all.
 | 
			
		||||
		$('[data-dropdown-tab="' + tab + '"]').addClass('show'); // Show the one we want.
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Mobile adjustments
 | 
			
		||||
	if (isMobile()) {
 | 
			
		||||
		// Add a class to the body for site-wide styling.
 | 
			
		||||
		document.body.className += ' ' + 'is-mobile';
 | 
			
		||||
 | 
			
		||||
		// Main dropdown menu stuff.
 | 
			
		||||
		// Click on a first level link.
 | 
			
		||||
		$dropdownTabsToggle.on('click', function(e){
 | 
			
		||||
			e.preventDefault(); // Don't go to the link (we'll show a menu instead)
 | 
			
		||||
			e.stopPropagation(); // Don't hide the menu (it'd trigger 'hide.bs.dropdown' event from bootstrap)
 | 
			
		||||
 | 
			
		||||
			let tab = $(this).data('tab-target');
 | 
			
		||||
 | 
			
		||||
			// Then display the corresponding sub-menu.
 | 
			
		||||
			dropdownTabShow(tab);
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
	} else {
 | 
			
		||||
		// If we're not on mobile, then we use hover on the menu items to trigger.
 | 
			
		||||
		$dropdownTabsToggle.hover(function(){
 | 
			
		||||
			let tab = $(this).data('tab-target');
 | 
			
		||||
 | 
			
		||||
			// On mouse hover the tab names, style it the 'active' class.
 | 
			
		||||
			$dropdownTabsToggle.removeClass('active'); // First make them all inactive.
 | 
			
		||||
			$(this).addClass('active'); // Make active the one we want.
 | 
			
		||||
 | 
			
		||||
			// Then display the corresponding sub-menu.
 | 
			
		||||
			dropdownTabShow(tab);
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// When toggling the main dropdown, also hide the tabs.
 | 
			
		||||
	// Otherwise they'll be already open the next time we toggle the main dropdown.
 | 
			
		||||
	$('.dropdown').on('hidden.bs.dropdown', function (e) {
 | 
			
		||||
		dropdownTabHideAll();
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Make it so clicking anywhere in the empty space in first level dropdown
 | 
			
		||||
	// also hides the tabs, that way we can 'go back' to browse the first level back and forth.
 | 
			
		||||
	$('.nav-main ul.nav:first').on('click', function (e) {
 | 
			
		||||
		if ($(this).parent().hasClass('show')){
 | 
			
		||||
			e.stopPropagation();
 | 
			
		||||
		}
 | 
			
		||||
		dropdownTabHideAll();
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -40,11 +40,6 @@ $(document).on('click','body .comment-action-reply',function(e){
 | 
			
		||||
	parentDiv.after(commentForm);
 | 
			
		||||
	// document.getElementById('comment_field').focus();
 | 
			
		||||
	$(commentField).focus();
 | 
			
		||||
 | 
			
		||||
	// Convert Markdown
 | 
			
		||||
	var convert = new Markdown.getSanitizingConverter().makeHtml;
 | 
			
		||||
	var preview = $('.comment-reply-preview-md');
 | 
			
		||||
	preview.html(convert($(commentField).val()));
 | 
			
		||||
	$('.comment-reply-field').addClass('filled');
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@@ -59,10 +54,6 @@ $(document).on('click','body .comment-action-cancel',function(e){
 | 
			
		||||
	delete commentField.dataset.originalParentId;
 | 
			
		||||
 | 
			
		||||
	$(commentField).val('');
 | 
			
		||||
	// Convert Markdown
 | 
			
		||||
	var convert = new Markdown.getSanitizingConverter().makeHtml;
 | 
			
		||||
	var preview = $('.comment-reply-preview-md');
 | 
			
		||||
	preview.html(convert($(commentField).val()));
 | 
			
		||||
 | 
			
		||||
	$('.comment-reply-field').removeClass('filled');
 | 
			
		||||
	$('.comment-container').removeClass('is-replying');
 | 
			
		||||
 
 | 
			
		||||
@@ -65,18 +65,21 @@ var elasticSearcher = (function() {
 | 
			
		||||
      return false;
 | 
			
		||||
    }),
 | 
			
		||||
 | 
			
		||||
    //get response from elastic and rebuild json
 | 
			
		||||
    //so we  can be a drop in of angolia
 | 
			
		||||
    execute: (function(){
 | 
			
		||||
      params = {
 | 
			
		||||
    getParams:(function(){
 | 
			
		||||
      var params = {
 | 
			
		||||
        q: deze.query,
 | 
			
		||||
        page: deze.page,
 | 
			
		||||
        project: deze.project_id,
 | 
			
		||||
      };
 | 
			
		||||
      //add term filters
 | 
			
		||||
      Object.assign(params, deze.terms);
 | 
			
		||||
      return params;
 | 
			
		||||
    }),
 | 
			
		||||
 | 
			
		||||
      var pstr = jQuery.param( params );
 | 
			
		||||
    //get response from elastic and rebuild json
 | 
			
		||||
    //so we  can be a drop in of angolia
 | 
			
		||||
    execute: (function(){
 | 
			
		||||
      var pstr = jQuery.param( deze.getParams() );
 | 
			
		||||
      if (pstr === deze.last_query) return;
 | 
			
		||||
 | 
			
		||||
      $.getJSON("/api/newsearch" + deze.url + "?"+ pstr)
 | 
			
		||||
@@ -117,6 +120,7 @@ var elasticSearcher = (function() {
 | 
			
		||||
    page: deze.page,
 | 
			
		||||
    toggleTerm: deze.toggleTerm,
 | 
			
		||||
    isRefined: deze.isRefined,
 | 
			
		||||
    getParams: deze.getParams,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
})();
 | 
			
		||||
@@ -155,114 +159,3 @@ var elasticSearch = (function($, url) {
 | 
			
		||||
 | 
			
		||||
}(jQuery));
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
$(document).ready(function() {
 | 
			
		||||
 | 
			
		||||
  var searchInput = $('#cloud-search');
 | 
			
		||||
  if (!searchInput.length) return;
 | 
			
		||||
 | 
			
		||||
  var tu = searchInput.typeahead({hint: true}, {
 | 
			
		||||
    //source: algoliaIndex.ttAdapter(),
 | 
			
		||||
    source: elasticSearch($),
 | 
			
		||||
    async: true,
 | 
			
		||||
    displayKey: 'name',
 | 
			
		||||
    limit: 9,  //  Above 10 it stops working from
 | 
			
		||||
               //  some magic reason
 | 
			
		||||
    minLength: 0,
 | 
			
		||||
    templates: {
 | 
			
		||||
      suggestion: function(hit) {
 | 
			
		||||
        var hitFree = (hit.is_free ? '<div class="search-hit-ribbon"><span>free</span></div>' : '');
 | 
			
		||||
        var hitPicture;
 | 
			
		||||
 | 
			
		||||
        if (hit.picture){
 | 
			
		||||
          hitPicture = '<img src="' + hit.picture + '"/>';
 | 
			
		||||
        } else {
 | 
			
		||||
          hitPicture = '<div class="search-hit-thumbnail-icon">';
 | 
			
		||||
          hitPicture += (hit.media ? '<i class="pi-' + hit.media + '"></i>' : '<i class="dark pi-'+ hit.node_type + '"></i>');
 | 
			
		||||
          hitPicture += '</div>';
 | 
			
		||||
        }
 | 
			
		||||
        var $span = $('<span>').addClass('project').text(hit.project.name);
 | 
			
		||||
        var $searchHitName = $('<div>').addClass('search-hit-name')
 | 
			
		||||
          .attr('title', hit.name)
 | 
			
		||||
          .text(hit.name);
 | 
			
		||||
 | 
			
		||||
        const $nodeType = $('<span>').addClass('node_type').text(hit.node_type);
 | 
			
		||||
        const hitMedia = (hit.media ? ' · ' + $('<span>').addClass('media').text(hit.media)[0].outerHTML : '');
 | 
			
		||||
 | 
			
		||||
        return $('<a/>', {
 | 
			
		||||
              href: '/nodes/'+ hit.objectID + '/redir',
 | 
			
		||||
              class: "search-site-result",
 | 
			
		||||
              id: hit.objectID
 | 
			
		||||
           }).append(
 | 
			
		||||
             '<div class="search-hit">' +
 | 
			
		||||
               '<div class="search-hit-thumbnail">' +
 | 
			
		||||
                 hitPicture +
 | 
			
		||||
                 hitFree +
 | 
			
		||||
               '</div>' +
 | 
			
		||||
               $searchHitName.html() +
 | 
			
		||||
               '<div class="search-hit-meta">' +
 | 
			
		||||
                 $span.html() + ' · ' +
 | 
			
		||||
                 $nodeType.html() +
 | 
			
		||||
                 hitMedia +
 | 
			
		||||
               '</div>' +
 | 
			
		||||
             '</div>'
 | 
			
		||||
          )
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  $('.search-site-result.advanced, .search-icon').on('click', function(e){
 | 
			
		||||
    e.stopPropagation();
 | 
			
		||||
    e.preventDefault();
 | 
			
		||||
    window.location.href = '/search?q='+ $("#cloud-search").val() + '&page=1';
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  searchInput.bind('typeahead:select', function(ev, hit) {
 | 
			
		||||
    $('.search-icon').removeClass('pi-search').addClass('pi-spin spin');
 | 
			
		||||
 | 
			
		||||
    window.location.href = '/nodes/'+ hit.objectID + '/redir';
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  searchInput.bind('typeahead:active', function() {
 | 
			
		||||
    $('#search-overlay').addClass('active');
 | 
			
		||||
    $('.page-body').addClass('blur');
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  searchInput.bind('typeahead:close', function() {
 | 
			
		||||
    $('#search-overlay').removeClass('active');
 | 
			
		||||
    $('.page-body').removeClass('blur');
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  searchInput.keyup(function(e) {
 | 
			
		||||
    if ( $('.tt-dataset').is(':empty') ){
 | 
			
		||||
      if(e.keyCode == 13){
 | 
			
		||||
        window.location.href = '/search#q='+ $("#cloud-search").val() + '&page=1';
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  searchInput.bind('typeahead:render', function(event, suggestions, async, dataset) {
 | 
			
		||||
    if( suggestions != undefined && $('.tt-all-results').length <= 0){
 | 
			
		||||
      $('.tt-dataset').append(
 | 
			
		||||
        $("<a/>", {
 | 
			
		||||
           id: "search-advanced",
 | 
			
		||||
           href: '/search?q='+ $("#cloud-search").val() + '&page=1',
 | 
			
		||||
           class: "search-site-result advanced tt-suggestion",
 | 
			
		||||
        }).append(
 | 
			
		||||
          '<div class="search-hit">' +
 | 
			
		||||
            '<div class="search-hit-thumbnail">' +
 | 
			
		||||
              '<div class="search-hit-thumbnail-icon">' +
 | 
			
		||||
                '<i class="pi-search"></i>' +
 | 
			
		||||
              '</div>' +
 | 
			
		||||
            '</div>' +
 | 
			
		||||
            '<div class="search-hit-name">' +
 | 
			
		||||
              'Use Advanced Search' +
 | 
			
		||||
            '</div>' +
 | 
			
		||||
          '</div>'
 | 
			
		||||
        )
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -400,10 +400,10 @@ nav.sidebar
 | 
			
		||||
			left: -19px
 | 
			
		||||
			z-index: 1
 | 
			
		||||
 | 
			
		||||
$loader-bar-width: 100px
 | 
			
		||||
$loader-bar-height: 2px
 | 
			
		||||
.loader-bar
 | 
			
		||||
	bottom: 0
 | 
			
		||||
$loading-bar-width: 100px
 | 
			
		||||
$loading-bar-height: 2px
 | 
			
		||||
.loading-bar
 | 
			
		||||
	bottom: -$loading-bar-height
 | 
			
		||||
	content: ''
 | 
			
		||||
	display: none
 | 
			
		||||
	height: 0
 | 
			
		||||
@@ -419,22 +419,22 @@ $loader-bar-height: 2px
 | 
			
		||||
		background-image: linear-gradient(to right, $primary-accent, $primary)
 | 
			
		||||
		content: ''
 | 
			
		||||
		display: block
 | 
			
		||||
		height: $loader-bar-height
 | 
			
		||||
		left: -$loader-bar-width
 | 
			
		||||
		height: $loading-bar-height
 | 
			
		||||
		left: -$loading-bar-width
 | 
			
		||||
		position: absolute
 | 
			
		||||
		width: $loader-bar-width
 | 
			
		||||
		width: $loading-bar-width
 | 
			
		||||
 | 
			
		||||
	&.active
 | 
			
		||||
		display: block
 | 
			
		||||
		height: $loader-bar-height
 | 
			
		||||
		height: $loading-bar-height
 | 
			
		||||
		visibility: visible
 | 
			
		||||
 | 
			
		||||
		&:before
 | 
			
		||||
			animation: loader-bar-slide 2s linear infinite
 | 
			
		||||
			animation: loading-bar-slide 2s linear infinite
 | 
			
		||||
 | 
			
		||||
@keyframes loader-bar-slide
 | 
			
		||||
@keyframes loading-bar-slide
 | 
			
		||||
	from
 | 
			
		||||
		left: -($loader-bar-width / 2)
 | 
			
		||||
		left: -($loading-bar-width / 2)
 | 
			
		||||
		width: 3%
 | 
			
		||||
 | 
			
		||||
	50%
 | 
			
		||||
 
 | 
			
		||||
@@ -32,7 +32,7 @@ $comments-width-max: 710px
 | 
			
		||||
		.comment-reply-container
 | 
			
		||||
			display: flex
 | 
			
		||||
			position: relative
 | 
			
		||||
			padding: 15px 0 20px 0
 | 
			
		||||
			padding: 15px 0
 | 
			
		||||
			transition: background-color 150ms ease-in-out, padding 150ms ease-in-out, margin 150ms ease-in-out
 | 
			
		||||
 | 
			
		||||
			&.comment-linked
 | 
			
		||||
@@ -196,8 +196,6 @@ $comments-width-max: 710px
 | 
			
		||||
						cursor: pointer
 | 
			
		||||
						font-family: 'pillar-font'
 | 
			
		||||
						height: 25px
 | 
			
		||||
						position: relative
 | 
			
		||||
						top: 4px
 | 
			
		||||
						width: 16px
 | 
			
		||||
 | 
			
		||||
					.comment-action-rating.up
 | 
			
		||||
@@ -286,10 +284,11 @@ $comments-width-max: 710px
 | 
			
		||||
					color: $color-success
 | 
			
		||||
 | 
			
		||||
			&.is-replying
 | 
			
		||||
				box-shadow: inset 5px 0 0 $color-primary
 | 
			
		||||
				box-shadow: -5px 0 0 $color-primary
 | 
			
		||||
				@extend .pl-3
 | 
			
		||||
 | 
			
		||||
			&.is-replying+.comment-reply-container
 | 
			
		||||
				box-shadow: inset 5px 0 0 $color-primary
 | 
			
		||||
				box-shadow: -5px 0 0 $color-primary
 | 
			
		||||
				margin-left: 0
 | 
			
		||||
				padding-left: 55px
 | 
			
		||||
 | 
			
		||||
@@ -500,3 +499,20 @@ $comments-width-max: 710px
 | 
			
		||||
		.comment-reply-meta
 | 
			
		||||
			button.comment-action-cancel
 | 
			
		||||
				display: inline-block
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
.comment-badges ul.blender-id-badges
 | 
			
		||||
	list-style: none
 | 
			
		||||
	padding: 0
 | 
			
		||||
	margin: 4px 0 0 0
 | 
			
		||||
 | 
			
		||||
	li
 | 
			
		||||
		margin: 2px 0 !important
 | 
			
		||||
 | 
			
		||||
	li, li a, li img
 | 
			
		||||
		padding: 0 !important
 | 
			
		||||
	li
 | 
			
		||||
		display: inline
 | 
			
		||||
	img
 | 
			
		||||
		width: 16px
 | 
			
		||||
		height: 16px
 | 
			
		||||
 
 | 
			
		||||
@@ -13,7 +13,7 @@ $font-body: 'Roboto' !default
 | 
			
		||||
$font-headings: 'Lato' !default
 | 
			
		||||
$font-size: 14px !default
 | 
			
		||||
$font-size-xs: .75rem
 | 
			
		||||
$font-size-xxs: .65rem
 | 
			
		||||
$font-size-xxs: .6rem
 | 
			
		||||
 | 
			
		||||
$color-text: #4d4e53 !default
 | 
			
		||||
$color-text-dark: $color-text !default
 | 
			
		||||
@@ -30,17 +30,20 @@ $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
 | 
			
		||||
$primary-accent: #0bd !default
 | 
			
		||||
 | 
			
		||||
$color-secondary: #f42942 !default
 | 
			
		||||
$color-secondary-light: hsl(hue($color-secondary), 30%, 90%) !default
 | 
			
		||||
$color-secondary-dark: hsl(hue($color-secondary), 80%, 40%) !default
 | 
			
		||||
$color-secondary-accent: hsl(hue($color-secondary), 100%, 50%) !default
 | 
			
		||||
 | 
			
		||||
$color-warning: #F3BB45 !default !default
 | 
			
		||||
$color-info: #68B3C8 !default !default
 | 
			
		||||
$color-success: #27AE60 !default !default
 | 
			
		||||
$color-danger: #EB5E28 !default !default
 | 
			
		||||
$color-warning: #F3BB45 !default
 | 
			
		||||
$color-info: #68B3C8 !default
 | 
			
		||||
$color-success: #27AE60 !default
 | 
			
		||||
$color-danger: #EB5E28 !default
 | 
			
		||||
 | 
			
		||||
$short-transition: 150ms
 | 
			
		||||
$long-transition: 650ms
 | 
			
		||||
 | 
			
		||||
/* Borrowed from dillo.space :) */
 | 
			
		||||
$color_upvote: #ff8b60 !default
 | 
			
		||||
@@ -101,7 +104,8 @@ $screen-md-max: $screen-lg-min - 1 !default
 | 
			
		||||
$sidebar-width: 40px !default
 | 
			
		||||
 | 
			
		||||
/* Project specifics */
 | 
			
		||||
$project_nav-width: 275px !default
 | 
			
		||||
$project_nav-width: 250px !default
 | 
			
		||||
$project_nav-width-xl: $project_nav-width * 1.4 !default
 | 
			
		||||
$project_nav-width-lg: $project_nav-width * 1.2 !default
 | 
			
		||||
$project_nav-width-md: $project_nav-width
 | 
			
		||||
$project_nav-width-sm: $project_nav-width * 0.8 !default
 | 
			
		||||
@@ -156,8 +160,12 @@ $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)
 | 
			
		||||
$btn-padding-y-sm: 0.1rem
 | 
			
		||||
 | 
			
		||||
$grid-breakpoints: (xs: 0,sm: 576px,md: 768px,lg: 1060px,xl: 1500px, xxl: 1800px)
 | 
			
		||||
 | 
			
		||||
$border-radius: .33rem
 | 
			
		||||
$btn-border-radius: .33rem
 | 
			
		||||
 
 | 
			
		||||
@@ -54,9 +54,9 @@
 | 
			
		||||
				padding: 5px
 | 
			
		||||
				text-decoration: none
 | 
			
		||||
				&.sign-up
 | 
			
		||||
					+button($color-primary, 3px, true)
 | 
			
		||||
					+button($color-primary, $btn-border-radius, true)
 | 
			
		||||
				&.sign-in
 | 
			
		||||
					+button($color-text-dark-primary, 3px)
 | 
			
		||||
					+button($color-text-dark-primary, $btn-border-radius)
 | 
			
		||||
 | 
			
		||||
#node-overlay
 | 
			
		||||
	#error-container
 | 
			
		||||
 
 | 
			
		||||
@@ -61,12 +61,12 @@
 | 
			
		||||
 | 
			
		||||
	#notifications-count
 | 
			
		||||
		align-items: center
 | 
			
		||||
		background-color: $color-secondary
 | 
			
		||||
		background-color: #f42942
 | 
			
		||||
		border-radius: 50%
 | 
			
		||||
		color: white
 | 
			
		||||
		display: flex
 | 
			
		||||
		font:
 | 
			
		||||
			size: .5em
 | 
			
		||||
			size: $font-size-xxs
 | 
			
		||||
			weight: bolder
 | 
			
		||||
			family: sans-serif
 | 
			
		||||
		height: 18px
 | 
			
		||||
@@ -74,14 +74,20 @@
 | 
			
		||||
		opacity: 0
 | 
			
		||||
		padding: 0
 | 
			
		||||
		position: absolute
 | 
			
		||||
		right: 14px
 | 
			
		||||
		right: 0
 | 
			
		||||
		text-align: center
 | 
			
		||||
		top: 10px
 | 
			
		||||
		top: 8px
 | 
			
		||||
		transform: scale(0)
 | 
			
		||||
		transition: transform 250ms ease-in-out
 | 
			
		||||
		user-select: none
 | 
			
		||||
		width: 18px
 | 
			
		||||
 | 
			
		||||
		/* The actual number. */
 | 
			
		||||
		span
 | 
			
		||||
			position: relative
 | 
			
		||||
			top: 0
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
#notifications-list
 | 
			
		||||
	list-style-type: none
 | 
			
		||||
 
 | 
			
		||||
@@ -369,7 +369,7 @@ a.page-card-cta
 | 
			
		||||
		margin-right: 0
 | 
			
		||||
 | 
			
		||||
	&.download
 | 
			
		||||
		+button($color-success, 3px, true)
 | 
			
		||||
		+button($color-success, $btn-border-radius, true)
 | 
			
		||||
		opacity: 1
 | 
			
		||||
		padding-left: 20px
 | 
			
		||||
		padding-right: 20px
 | 
			
		||||
 
 | 
			
		||||
@@ -178,7 +178,7 @@
 | 
			
		||||
						display: block
 | 
			
		||||
						margin: 15px auto 0 auto
 | 
			
		||||
						width: 200px
 | 
			
		||||
						+button($color-success, 3px)
 | 
			
		||||
						+button($color-success, $btn-border-radius)
 | 
			
		||||
						padding: 5px 10px
 | 
			
		||||
				.blender_sync-main-last
 | 
			
		||||
					text-align: right
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,12 @@
 | 
			
		||||
$node-latest-thumbnail-size: 160px
 | 
			
		||||
 | 
			
		||||
body.open-projects,
 | 
			
		||||
body.courses,
 | 
			
		||||
body.workshops
 | 
			
		||||
	#project-container
 | 
			
		||||
		+container-behavior
 | 
			
		||||
/* Dark navbar when browsing a project. */
 | 
			
		||||
body.project,
 | 
			
		||||
body.edit, body.sharing, body.attract, body.flamenco,
 | 
			
		||||
body.svnman, body.edit_node_types, body.search-project
 | 
			
		||||
	nav.navbar
 | 
			
		||||
		@extend .navbar-dark
 | 
			
		||||
		padding-right: 5px
 | 
			
		||||
 | 
			
		||||
#project-container
 | 
			
		||||
	display: flex
 | 
			
		||||
@@ -26,30 +28,35 @@ body.workshops
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
#project_nav,
 | 
			
		||||
#project_tree,
 | 
			
		||||
#project_nav-container
 | 
			
		||||
	+media-lg
 | 
			
		||||
		width: $project_nav-width-lg
 | 
			
		||||
	+media-sm
 | 
			
		||||
		width: $project_nav-width-sm
 | 
			
		||||
	+media-xs
 | 
			
		||||
		width: $project_nav-width-xs
 | 
			
		||||
	+media-sm
 | 
			
		||||
		width: $project_nav-width-sm
 | 
			
		||||
	+media-md
 | 
			
		||||
		width: $project_nav-width-md
 | 
			
		||||
	+media-lg
 | 
			
		||||
		width: $project_nav-width-lg
 | 
			
		||||
	+media-xl
 | 
			
		||||
		width: $project_nav-width-xl
 | 
			
		||||
 | 
			
		||||
	width: $project_nav-width
 | 
			
		||||
 | 
			
		||||
#project_nav-container
 | 
			
		||||
	+media-xs
 | 
			
		||||
		display: block
 | 
			
		||||
		width: 100%
 | 
			
		||||
		position: relative
 | 
			
		||||
		height: initial !important
 | 
			
		||||
		position: relative
 | 
			
		||||
 | 
			
		||||
	position: fixed
 | 
			
		||||
	z-index: $z-index-base + 5
 | 
			
		||||
 | 
			
		||||
#project_sidebar
 | 
			
		||||
	box-shadow: inset -1px 0 0 0 $color-background
 | 
			
		||||
	flex-shrink: 0
 | 
			
		||||
	width: $project-sidebar-width
 | 
			
		||||
	z-index: $z-index-base + 6
 | 
			
		||||
	box-shadow: inset -1px 0 0 0 $color-background
 | 
			
		||||
 | 
			
		||||
	+media-xs
 | 
			
		||||
		width: 100%
 | 
			
		||||
@@ -94,36 +101,11 @@ body.workshops
 | 
			
		||||
					width: $project-sidebar-width
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
#search-container #project_sidebar ul.project-tabs li.tabs-thumbnail
 | 
			
		||||
	background-color: $color-background-nav-dark
 | 
			
		||||
	&:hover
 | 
			
		||||
		background-color: $color-background-nav-light
 | 
			
		||||
 | 
			
		||||
#project-nav,
 | 
			
		||||
#project_context-container
 | 
			
		||||
	flex: 1
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/* Container for navigation on the left */
 | 
			
		||||
#project_nav
 | 
			
		||||
	+media-lg
 | 
			
		||||
		width: $project_nav-width-lg
 | 
			
		||||
	+media-sm
 | 
			
		||||
		width: $project_nav-width-sm
 | 
			
		||||
	+media-xs
 | 
			
		||||
		width: $project_nav-width-xs
 | 
			
		||||
 | 
			
		||||
	display: block
 | 
			
		||||
	left: 0
 | 
			
		||||
	position: relative
 | 
			
		||||
	visibility: visible
 | 
			
		||||
	width: $project_nav-width
 | 
			
		||||
 | 
			
		||||
	&.about
 | 
			
		||||
		display: none
 | 
			
		||||
		visibility: hidden
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/* Header with name and node edit tools */
 | 
			
		||||
#project_context-header
 | 
			
		||||
	right: 0
 | 
			
		||||
@@ -494,42 +476,26 @@ $node-preview-max-height-lg: 700px
 | 
			
		||||
				text-transform: uppercase
 | 
			
		||||
 | 
			
		||||
section.node-preview
 | 
			
		||||
	+media-md
 | 
			
		||||
		max-height: $node-preview-max-height-md
 | 
			
		||||
	+media-lg
 | 
			
		||||
		max-height: $node-preview-max-height-lg
 | 
			
		||||
 | 
			
		||||
	align-items: center
 | 
			
		||||
	background-color: black
 | 
			
		||||
	color: $color-text-light-primary
 | 
			
		||||
	// display: flex
 | 
			
		||||
	justify-content: center
 | 
			
		||||
	max-height: 500px
 | 
			
		||||
	// min-height: 200px
 | 
			
		||||
	// overflow: hidden
 | 
			
		||||
	flex-shrink: 0 // prevents content/comments to make preview dissappear
 | 
			
		||||
	max-height: calc((9 / 16) * 133vh)
 | 
			
		||||
	overflow: hidden
 | 
			
		||||
	min-height: 200px
 | 
			
		||||
 | 
			
		||||
	iframe
 | 
			
		||||
		width: 100%
 | 
			
		||||
 | 
			
		||||
	img
 | 
			
		||||
		display: block
 | 
			
		||||
		max-height: $node-preview-max-height-lg
 | 
			
		||||
		@extend .d-block
 | 
			
		||||
		@extend .mx-auto
 | 
			
		||||
		max-width: 100%
 | 
			
		||||
		object-fit: scale-down
 | 
			
		||||
		flex: 1
 | 
			
		||||
 | 
			
		||||
		+media-xs
 | 
			
		||||
			width: 100%
 | 
			
		||||
 | 
			
		||||
		+media-md
 | 
			
		||||
			max-height: $node-preview-max-height-md
 | 
			
		||||
		+media-lg
 | 
			
		||||
			max-height: $node-preview-max-height-lg
 | 
			
		||||
 | 
			
		||||
	&.image
 | 
			
		||||
		cursor: zoom-in
 | 
			
		||||
		display: flex
 | 
			
		||||
		overflow: hidden
 | 
			
		||||
 | 
			
		||||
	&.video
 | 
			
		||||
		background-color: black
 | 
			
		||||
		position: relative
 | 
			
		||||
@@ -583,19 +549,10 @@ section.node-preview
 | 
			
		||||
							color: $color-warning
 | 
			
		||||
							margin-right: 10px
 | 
			
		||||
 | 
			
		||||
	&.project
 | 
			
		||||
		background-color: black
 | 
			
		||||
		width: 100%
 | 
			
		||||
 | 
			
		||||
		img
 | 
			
		||||
			max-height: 800px
 | 
			
		||||
			max-width: 100%
 | 
			
		||||
			object-fit: cover
 | 
			
		||||
			width: 100%
 | 
			
		||||
 | 
			
		||||
section.node-preview-forbidden
 | 
			
		||||
	align-items: center
 | 
			
		||||
	background-color: $color-background-nav
 | 
			
		||||
	background-color: $primary
 | 
			
		||||
	color: $color-text-light
 | 
			
		||||
	cursor: default
 | 
			
		||||
	display: flex
 | 
			
		||||
@@ -845,7 +802,7 @@ a.learn-more
 | 
			
		||||
	font-size: .9em
 | 
			
		||||
	margin-left: 20px
 | 
			
		||||
	padding: 5px 10px
 | 
			
		||||
	+button($color-info, 3px)
 | 
			
		||||
	+button($color-info, $btn-border-radius)
 | 
			
		||||
 | 
			
		||||
.node-extra
 | 
			
		||||
	display: flex
 | 
			
		||||
@@ -1033,7 +990,7 @@ a.learn-more
 | 
			
		||||
		width: auto
 | 
			
		||||
		height: auto
 | 
			
		||||
		background: black
 | 
			
		||||
		box-shadow: 1px 1px 1px rgba(black, .5), 2px 2px 15px rgba(black, .5)
 | 
			
		||||
		box-shadow: 1px 1px 1px rgba(black, .5), 2px 2px 25px rgba(black, .25)
 | 
			
		||||
 | 
			
		||||
		visibility: hidden
 | 
			
		||||
		display: none
 | 
			
		||||
@@ -1070,7 +1027,7 @@ a.learn-more
 | 
			
		||||
			top: 0
 | 
			
		||||
			left: 0
 | 
			
		||||
			right: 0
 | 
			
		||||
			@include overlay(rgba($color-background-nav, .9), 0%, transparent, 25%)
 | 
			
		||||
			@include overlay(rgba(black, .9), 0%, transparent, 25%)
 | 
			
		||||
 | 
			
		||||
			+text-overflow-ellipsis
 | 
			
		||||
 | 
			
		||||
@@ -1290,13 +1247,13 @@ a.learn-more
 | 
			
		||||
 | 
			
		||||
		.list-node-children-item-thumbnail
 | 
			
		||||
			height: $list-node-children-item-width
 | 
			
		||||
			background-color: $color-background-nav
 | 
			
		||||
			background-color: black
 | 
			
		||||
 | 
			
		||||
		&:hover
 | 
			
		||||
			opacity: 1
 | 
			
		||||
 | 
			
		||||
			img
 | 
			
		||||
				opacity: .15
 | 
			
		||||
				opacity: .5
 | 
			
		||||
 | 
			
		||||
			.list-node-children-item-name
 | 
			
		||||
				opacity: 1
 | 
			
		||||
@@ -1727,7 +1684,7 @@ a.learn-more
 | 
			
		||||
			width: 100%
 | 
			
		||||
 | 
			
		||||
		#files-action-add
 | 
			
		||||
			+button($color-success, 3px, true)
 | 
			
		||||
			+button($color-success, $btn-border-radius, true)
 | 
			
		||||
			width: 200px
 | 
			
		||||
			padding: 5px 10px
 | 
			
		||||
			margin-bottom: 10px
 | 
			
		||||
@@ -1860,10 +1817,10 @@ a.learn-more
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
			button.cancel
 | 
			
		||||
				+button($color-text-dark-primary, 3px)
 | 
			
		||||
				+button($color-text-dark-primary, $btn-border-radius)
 | 
			
		||||
 | 
			
		||||
			button.move
 | 
			
		||||
				+button($color-success, 3px, true)
 | 
			
		||||
				+button($color-success, $btn-border-radius, true)
 | 
			
		||||
 | 
			
		||||
				&.disabled
 | 
			
		||||
					pointer-events: none
 | 
			
		||||
@@ -1885,27 +1842,27 @@ a.learn-more
 | 
			
		||||
	.fileupload-buttonbar
 | 
			
		||||
		padding: 10px 0
 | 
			
		||||
		.fileinput-button
 | 
			
		||||
			+button($color-success, 3px)
 | 
			
		||||
			+button($color-success, $btn-border-radius)
 | 
			
		||||
		.start
 | 
			
		||||
			+button($color-info, 3px)
 | 
			
		||||
			+button($color-info, $btn-border-radius)
 | 
			
		||||
		.cancel
 | 
			
		||||
			+button($color-warning, 3px)
 | 
			
		||||
			+button($color-warning, $btn-border-radius)
 | 
			
		||||
		.delete
 | 
			
		||||
			+button($danger, 3px)
 | 
			
		||||
			+button($danger, $btn-border-radius)
 | 
			
		||||
		.toggle
 | 
			
		||||
			margin: 0 20px
 | 
			
		||||
 | 
			
		||||
	.files
 | 
			
		||||
		.btn
 | 
			
		||||
			&.start
 | 
			
		||||
				+button($color-info, 3px)
 | 
			
		||||
				+button($color-info, $btn-border-radius)
 | 
			
		||||
			&.cancel
 | 
			
		||||
				+button($color-warning, 3px)
 | 
			
		||||
				+button($color-warning, $btn-border-radius)
 | 
			
		||||
			&.delete
 | 
			
		||||
				+button($danger, 3px)
 | 
			
		||||
				+button($danger, $btn-border-radius)
 | 
			
		||||
 | 
			
		||||
			&.create
 | 
			
		||||
				+button($color-success, 3px)
 | 
			
		||||
				+button($color-success, $btn-border-radius)
 | 
			
		||||
 | 
			
		||||
	.template-upload,
 | 
			
		||||
	.template-download
 | 
			
		||||
@@ -1972,11 +1929,11 @@ a.learn-more
 | 
			
		||||
 | 
			
		||||
	a.cta
 | 
			
		||||
		padding: 5px 35px
 | 
			
		||||
		+button($color-primary, 3px, true)
 | 
			
		||||
		+button($color-primary, $btn-border-radius, true)
 | 
			
		||||
 | 
			
		||||
	a.cta-learn-more
 | 
			
		||||
		padding: 5px 20px
 | 
			
		||||
		+button(white, 3px)
 | 
			
		||||
		+button(white, $btn-border-radius)
 | 
			
		||||
		display: inline-block
 | 
			
		||||
		margin: 25px 0 25px 15px
 | 
			
		||||
		padding: 5px 35px
 | 
			
		||||
@@ -2021,5 +1978,3 @@ a.learn-more
 | 
			
		||||
				padding: 5px 35px
 | 
			
		||||
				text-align: center
 | 
			
		||||
 | 
			
		||||
.ribbon
 | 
			
		||||
	+ribbon
 | 
			
		||||
 
 | 
			
		||||
@@ -93,6 +93,17 @@ $search-hit-width_grid: 100px
 | 
			
		||||
				background-color: lighten($color-background, 5%)
 | 
			
		||||
 | 
			
		||||
.search-list
 | 
			
		||||
	width: 50%
 | 
			
		||||
 | 
			
		||||
	.embed-responsive
 | 
			
		||||
		width: 100px
 | 
			
		||||
		min-width: 100px
 | 
			
		||||
 | 
			
		||||
	.card-deck.card-deck-vertical
 | 
			
		||||
		.card
 | 
			
		||||
			flex-wrap: initial
 | 
			
		||||
 | 
			
		||||
.search-settings
 | 
			
		||||
	width: 30%
 | 
			
		||||
 | 
			
		||||
	.card-deck.card-deck-vertical
 | 
			
		||||
@@ -119,8 +130,14 @@ $search-hit-width_grid: 100px
 | 
			
		||||
.search-details
 | 
			
		||||
	width: 70%
 | 
			
		||||
 | 
			
		||||
	.container-fluid .col-md-8
 | 
			
		||||
		flex: 1
 | 
			
		||||
		max-width: 100%
 | 
			
		||||
		width: 100%
 | 
			
		||||
 | 
			
		||||
#search-details
 | 
			
		||||
	position: relative
 | 
			
		||||
 | 
			
		||||
	#search-hit-container
 | 
			
		||||
		position: absolute // for scrollbars
 | 
			
		||||
		overflow-y: auto
 | 
			
		||||
@@ -289,24 +306,13 @@ $search-hit-width_grid: 100px
 | 
			
		||||
								button
 | 
			
		||||
									width: 100%
 | 
			
		||||
 | 
			
		||||
#project_sidebar+#search-sidebar,
 | 
			
		||||
#project_sidebar+#search-sidebar+#search-container
 | 
			
		||||
	padding-left: $sidebar-width
 | 
			
		||||
 | 
			
		||||
.search-project
 | 
			
		||||
	li.project
 | 
			
		||||
		display: none
 | 
			
		||||
 | 
			
		||||
#search-sidebar
 | 
			
		||||
	.card
 | 
			
		||||
		margin-bottom: 10px
 | 
			
		||||
		border-radius: 3px
 | 
			
		||||
		border: none
 | 
			
		||||
		background-color: white
 | 
			
		||||
		box-shadow: 1px 1px 0 rgba(black, .1)
 | 
			
		||||
 | 
			
		||||
	a
 | 
			
		||||
		text-decoration: none
 | 
			
		||||
	.facet-title
 | 
			
		||||
		text-transform: capitalize
 | 
			
		||||
 | 
			
		||||
	.toggleRefine
 | 
			
		||||
		display: block
 | 
			
		||||
@@ -346,22 +352,6 @@ $search-hit-width_grid: 100px
 | 
			
		||||
				position: relative
 | 
			
		||||
				left: -7px
 | 
			
		||||
				font-size: .9em
 | 
			
		||||
	.facet_count
 | 
			
		||||
		color: $color-text-dark-secondary
 | 
			
		||||
 | 
			
		||||
	.card-title
 | 
			
		||||
		position: relative
 | 
			
		||||
		&:after
 | 
			
		||||
			content: '\e83b'
 | 
			
		||||
			font-family: 'pillar-font'
 | 
			
		||||
			position: absolute
 | 
			
		||||
			right: 0
 | 
			
		||||
			color: $color-text-dark-primary
 | 
			
		||||
 | 
			
		||||
	.collapsed
 | 
			
		||||
		.card-title:after
 | 
			
		||||
			content: '\e838'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
	.search-list-stats
 | 
			
		||||
		color: $color-text-dark-hint
 | 
			
		||||
@@ -493,4 +483,4 @@ $search-hit-width_grid: 100px
 | 
			
		||||
			&.active
 | 
			
		||||
				color: white
 | 
			
		||||
				background-color: $primary
 | 
			
		||||
				border-color: transparent
 | 
			
		||||
				border-color: transparent
 | 
			
		||||
@@ -24,19 +24,18 @@
 | 
			
		||||
    text-align: center
 | 
			
		||||
 | 
			
		||||
  .login-info
 | 
			
		||||
    color: $color-text-dark-primary
 | 
			
		||||
    font-weight: 300
 | 
			
		||||
    text-align: center
 | 
			
		||||
 | 
			
		||||
  $provider-color-facebook: #3b5998
 | 
			
		||||
  $provider-color-google: #dd4b39
 | 
			
		||||
  $provider-color-blender_id: #00bcef
 | 
			
		||||
  $provider-color-blender_id: #0facf0
 | 
			
		||||
  .login-providers
 | 
			
		||||
    align-items: center
 | 
			
		||||
    display: flex
 | 
			
		||||
    flex-direction: column
 | 
			
		||||
    justify-content: center
 | 
			
		||||
    margin: 15px
 | 
			
		||||
    margin: 20px 15px
 | 
			
		||||
 | 
			
		||||
    .login-provider-button
 | 
			
		||||
      background-color: $provider-color-blender_id
 | 
			
		||||
@@ -87,10 +86,10 @@
 | 
			
		||||
    padding: 10px 0
 | 
			
		||||
 | 
			
		||||
    #submit_edit_user
 | 
			
		||||
      +button($color-success, 3px, true)
 | 
			
		||||
      +button($color-success, $btn-border-radius, true)
 | 
			
		||||
 | 
			
		||||
    #button-cancel
 | 
			
		||||
      +button(#aaa, 3px)
 | 
			
		||||
      +button(#aaa, $btn-border-radius)
 | 
			
		||||
      margin: 0 10px
 | 
			
		||||
 | 
			
		||||
    #user-edit-notification
 | 
			
		||||
 
 | 
			
		||||
@@ -114,7 +114,7 @@
 | 
			
		||||
 | 
			
		||||
=container-box
 | 
			
		||||
	position: relative
 | 
			
		||||
	background-color: white
 | 
			
		||||
	background-color: $color-background-light
 | 
			
		||||
	border-radius: 3px
 | 
			
		||||
	box-shadow: rgba(0, 0, 0, 0.298039) 0px 1px 4px -1px
 | 
			
		||||
 | 
			
		||||
@@ -130,17 +130,20 @@
 | 
			
		||||
	transform: translate(-50%, -50%)
 | 
			
		||||
 | 
			
		||||
=input-generic
 | 
			
		||||
	// padding: 5px 5px 5px 0
 | 
			
		||||
	color: $color-text-dark
 | 
			
		||||
	background-color: transparent
 | 
			
		||||
	border-color: $color-background-dark
 | 
			
		||||
	color: $color-text
 | 
			
		||||
	transition: background-color 150ms ease-in-out, border-color 150ms ease-in-out
 | 
			
		||||
 | 
			
		||||
	&:hover
 | 
			
		||||
		border-bottom-color: $color-background
 | 
			
		||||
		border-color: $color-background-light
 | 
			
		||||
 | 
			
		||||
	&:focus
 | 
			
		||||
		outline: 0
 | 
			
		||||
		background-color: unset
 | 
			
		||||
		border-color: $primary
 | 
			
		||||
		box-shadow: none
 | 
			
		||||
		color: $color-text
 | 
			
		||||
		outline: 0
 | 
			
		||||
 | 
			
		||||
=label-generic
 | 
			
		||||
	color: $color-text-dark-primary
 | 
			
		||||
@@ -304,6 +307,12 @@
 | 
			
		||||
	100%
 | 
			
		||||
		transform: scale(1.0)
 | 
			
		||||
 | 
			
		||||
@keyframes fade-in
 | 
			
		||||
	0%
 | 
			
		||||
		opacity: 0
 | 
			
		||||
	100%
 | 
			
		||||
		opacity: 1
 | 
			
		||||
 | 
			
		||||
@keyframes grow-bounce-out
 | 
			
		||||
	0
 | 
			
		||||
		transform: scale(1.0)
 | 
			
		||||
@@ -349,28 +358,29 @@
 | 
			
		||||
 | 
			
		||||
=list-bullets
 | 
			
		||||
	ul
 | 
			
		||||
		padding-left: 20px
 | 
			
		||||
		padding-left: 25px
 | 
			
		||||
		list-style: none
 | 
			
		||||
 | 
			
		||||
		li
 | 
			
		||||
			position: relative
 | 
			
		||||
 | 
			
		||||
		li:before
 | 
			
		||||
			content: '·'
 | 
			
		||||
			font-weight: 400
 | 
			
		||||
			position: relative
 | 
			
		||||
			left: -10px
 | 
			
		||||
			left: -20px
 | 
			
		||||
			position: absolute
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
=node-details-description
 | 
			
		||||
	+clearfix
 | 
			
		||||
	color: darken($color-text-dark, 5%)
 | 
			
		||||
	font:
 | 
			
		||||
		weight: 300
 | 
			
		||||
		size: 1.2em
 | 
			
		||||
 | 
			
		||||
	color: $color-text
 | 
			
		||||
	font-size: 1.25em
 | 
			
		||||
	word-break: break-word
 | 
			
		||||
 | 
			
		||||
	+media-xs
 | 
			
		||||
		font-size: 1.1em
 | 
			
		||||
 | 
			
		||||
	/* Style links without a class. Usually regular
 | 
			
		||||
	 * links in a comment or node description. */
 | 
			
		||||
	a:not([class])
 | 
			
		||||
		color: $color-text-dark-primary
 | 
			
		||||
		text-decoration: underline
 | 
			
		||||
@@ -383,11 +393,6 @@
 | 
			
		||||
		line-height: 1.5em
 | 
			
		||||
		word-wrap: break-word
 | 
			
		||||
 | 
			
		||||
	h1, h2, h3, h4, h5, h6
 | 
			
		||||
		padding:
 | 
			
		||||
			top: 20px
 | 
			
		||||
			right: 20px
 | 
			
		||||
 | 
			
		||||
	blockquote
 | 
			
		||||
		background-color: lighten($color-background-light, 5%)
 | 
			
		||||
		box-shadow: inset 5px 0 0 $color-background
 | 
			
		||||
@@ -408,14 +413,15 @@
 | 
			
		||||
	img,
 | 
			
		||||
	p img,
 | 
			
		||||
	ul li img
 | 
			
		||||
		@extend .d-block
 | 
			
		||||
		@extend .mx-auto
 | 
			
		||||
		@extend .my-3
 | 
			
		||||
		max-width: 100%
 | 
			
		||||
		padding:
 | 
			
		||||
			bottom: 25px
 | 
			
		||||
			top: 25px
 | 
			
		||||
 | 
			
		||||
		&.emoji
 | 
			
		||||
			display: inline-block
 | 
			
		||||
			display: inline-block !important
 | 
			
		||||
			padding: initial
 | 
			
		||||
			margin-bottom: initial !important
 | 
			
		||||
 | 
			
		||||
	h2
 | 
			
		||||
		margin-bottom: 15px
 | 
			
		||||
@@ -424,25 +430,13 @@
 | 
			
		||||
			font-size: 1.5em
 | 
			
		||||
 | 
			
		||||
	/* e.g. YouTube embed */
 | 
			
		||||
	iframe
 | 
			
		||||
		height: auto
 | 
			
		||||
		margin: 15px auto
 | 
			
		||||
	iframe, video
 | 
			
		||||
		max-width: 100%
 | 
			
		||||
		min-height: 500px
 | 
			
		||||
		width: 100%
 | 
			
		||||
		@extend .mx-auto
 | 
			
		||||
 | 
			
		||||
	+media-sm
 | 
			
		||||
		iframe
 | 
			
		||||
			min-height: 314px
 | 
			
		||||
	+media-xs
 | 
			
		||||
		iframe
 | 
			
		||||
			min-height: 314px
 | 
			
		||||
 | 
			
		||||
	iframe[src^="https://www.youtube"]
 | 
			
		||||
		+media-xs
 | 
			
		||||
			iframe
 | 
			
		||||
				min-height: 420px
 | 
			
		||||
		min-height: 500px
 | 
			
		||||
	.embed-responsive,
 | 
			
		||||
	video
 | 
			
		||||
		@extend .my-3
 | 
			
		||||
 | 
			
		||||
	iframe[src^="https://w.soundcloud"]
 | 
			
		||||
		min-height: auto
 | 
			
		||||
@@ -451,7 +445,6 @@
 | 
			
		||||
 | 
			
		||||
	ul
 | 
			
		||||
		margin-bottom: 25px
 | 
			
		||||
		padding-left: 40px
 | 
			
		||||
 | 
			
		||||
		li
 | 
			
		||||
			margin-bottom: 7px
 | 
			
		||||
@@ -531,6 +524,27 @@
 | 
			
		||||
		margin: 1px 0
 | 
			
		||||
		padding: 3px 50px
 | 
			
		||||
 | 
			
		||||
.ribbon
 | 
			
		||||
	+ribbon
 | 
			
		||||
 | 
			
		||||
=label-tiny
 | 
			
		||||
	position: relative
 | 
			
		||||
 | 
			
		||||
	&:before
 | 
			
		||||
		color: $color-danger
 | 
			
		||||
		display: block
 | 
			
		||||
		font-size: 8px
 | 
			
		||||
		font-weight: bold
 | 
			
		||||
		left: 100%
 | 
			
		||||
		position: absolute
 | 
			
		||||
		top: -4px
 | 
			
		||||
 | 
			
		||||
.new
 | 
			
		||||
	+label-tiny
 | 
			
		||||
 | 
			
		||||
	&:before
 | 
			
		||||
		content: 'NEW'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@mixin text-background($text-color, $background-color, $roundness, $padding)
 | 
			
		||||
	border-radius: $roundness
 | 
			
		||||
@@ -570,9 +584,7 @@
 | 
			
		||||
 | 
			
		||||
/* Bootstrap's img-responsive class */
 | 
			
		||||
=img-responsive
 | 
			
		||||
	display: block
 | 
			
		||||
	max-width: 100%
 | 
			
		||||
	height: auto
 | 
			
		||||
	@extend .img-fluid
 | 
			
		||||
 | 
			
		||||
/* Set the color for a specified property
 | 
			
		||||
 * 1: $property: e.g. background-color
 | 
			
		||||
@@ -664,6 +676,9 @@
 | 
			
		||||
.cursor-pointer
 | 
			
		||||
	cursor: pointer
 | 
			
		||||
 | 
			
		||||
.cursor-zoom-in
 | 
			
		||||
	cursor: zoom-in
 | 
			
		||||
 | 
			
		||||
.user-select-none
 | 
			
		||||
	user-select: none
 | 
			
		||||
 | 
			
		||||
@@ -692,3 +707,17 @@
 | 
			
		||||
 | 
			
		||||
	&:before
 | 
			
		||||
		+text-gradient($primary-accent, $primary)
 | 
			
		||||
 | 
			
		||||
.title-underline
 | 
			
		||||
	padding-bottom: 5px
 | 
			
		||||
	position: relative
 | 
			
		||||
	margin-bottom: 20px
 | 
			
		||||
 | 
			
		||||
	&:before
 | 
			
		||||
		background-color: $primary
 | 
			
		||||
		content: ' '
 | 
			
		||||
		display: block
 | 
			
		||||
		height: 2px
 | 
			
		||||
		top: 125%
 | 
			
		||||
		position: absolute
 | 
			
		||||
		width: 50px
 | 
			
		||||
 
 | 
			
		||||
@@ -16,6 +16,7 @@
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/code"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/grid"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/buttons"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/badge"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/dropdown"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/custom-forms"
 | 
			
		||||
 | 
			
		||||
@@ -45,6 +46,7 @@
 | 
			
		||||
@import "components/buttons"
 | 
			
		||||
@import "components/tooltip"
 | 
			
		||||
@import "components/overlay"
 | 
			
		||||
@import "components/search"
 | 
			
		||||
 | 
			
		||||
@import _comments
 | 
			
		||||
@import _notifications
 | 
			
		||||
 
 | 
			
		||||
@@ -11,16 +11,16 @@ body
 | 
			
		||||
		max-width: 100%
 | 
			
		||||
		min-width: auto
 | 
			
		||||
 | 
			
		||||
.container
 | 
			
		||||
	+media-xs
 | 
			
		||||
		max-width: 100%
 | 
			
		||||
		min-width: auto
 | 
			
		||||
		padding:
 | 
			
		||||
			left: 0
 | 
			
		||||
			right: 0
 | 
			
		||||
body.has-overlay
 | 
			
		||||
	overflow: hidden
 | 
			
		||||
	padding-right: 5px
 | 
			
		||||
 | 
			
		||||
	&.box
 | 
			
		||||
		+container-box
 | 
			
		||||
	.page-body
 | 
			
		||||
		filter: blur(15px)
 | 
			
		||||
		transition: filter $short-transition
 | 
			
		||||
 | 
			
		||||
	.card
 | 
			
		||||
		box-shadow: 1px 1px rgba(black, .1), 1px 10px 25px rgba(black, .05)
 | 
			
		||||
 | 
			
		||||
.page-content
 | 
			
		||||
	background-color: $white
 | 
			
		||||
 
 | 
			
		||||
@@ -12,3 +12,10 @@
 | 
			
		||||
 | 
			
		||||
	&:focus, &:active
 | 
			
		||||
		box-shadow: none
 | 
			
		||||
 | 
			
		||||
.btn-primary
 | 
			
		||||
	background: linear-gradient(135deg, $primary-accent, $primary)
 | 
			
		||||
 | 
			
		||||
.btn-primary:hover,
 | 
			
		||||
.btn-outline-primary:hover
 | 
			
		||||
	background: linear-gradient(135deg, lighten($primary-accent, 5%), $primary)
 | 
			
		||||
 
 | 
			
		||||
@@ -1,47 +1,57 @@
 | 
			
		||||
.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
 | 
			
		||||
			margin: 0 0 20px 0
 | 
			
		||||
			$card-width-percentage: 30%
 | 
			
		||||
			flex: 1 0 $card-width-percentage
 | 
			
		||||
			max-width: $card-width-percentage
 | 
			
		||||
 | 
			
		||||
			+media-xs
 | 
			
		||||
				$card-width-percentage: 100%
 | 
			
		||||
				flex: 1 0 $card-width-percentage
 | 
			
		||||
				max-width: $card-width-percentage
 | 
			
		||||
 | 
			
		||||
			+media-sm
 | 
			
		||||
				flex: 1 0 50%
 | 
			
		||||
				max-width: 50%
 | 
			
		||||
				$card-width-percentage: 46%
 | 
			
		||||
				flex: 1 0 $card-width-percentage
 | 
			
		||||
				max-width: $card-width-percentage
 | 
			
		||||
				margin-right: 100% / ($card-width-percentage / 1%)
 | 
			
		||||
 | 
			
		||||
			+media-md
 | 
			
		||||
				flex: 1 0 33%
 | 
			
		||||
				max-width: 33%
 | 
			
		||||
				$card-width-percentage: 30%
 | 
			
		||||
				flex: 1 0 $card-width-percentage
 | 
			
		||||
				max-width: $card-width-percentage
 | 
			
		||||
				margin-right: 100% / ($card-width-percentage / 1%)
 | 
			
		||||
 | 
			
		||||
			+media-lg
 | 
			
		||||
				flex: 1 0 33%
 | 
			
		||||
				max-width: 33%
 | 
			
		||||
				$card-width-percentage: 30%
 | 
			
		||||
				flex: 1 0 $card-width-percentage
 | 
			
		||||
				max-width: $card-width-percentage
 | 
			
		||||
				margin-right: 100% / ($card-width-percentage / 1%)
 | 
			
		||||
 | 
			
		||||
			+media-xl
 | 
			
		||||
				flex: 1 0 25%
 | 
			
		||||
				max-width: 25%
 | 
			
		||||
				$card-width-percentage: 22%
 | 
			
		||||
				flex: 1 0 $card-width-percentage
 | 
			
		||||
				max-width: $card-width-percentage
 | 
			
		||||
				margin-right: ($card-width-percentage * 2) / ($card-width-percentage / 1%)
 | 
			
		||||
 | 
			
		||||
			+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-width-percentage: 22%
 | 
			
		||||
				flex: 1 0 $card-width-percentage
 | 
			
		||||
				max-width: $card-width-percentage
 | 
			
		||||
				margin-right: ($card-width-percentage * 2) / ($card-width-percentage / 1%)
 | 
			
		||||
 | 
			
		||||
	&.card-deck-vertical
 | 
			
		||||
		@extend .flex-column
 | 
			
		||||
		@extend .text-truncate
 | 
			
		||||
		flex-wrap: initial
 | 
			
		||||
 | 
			
		||||
		.card
 | 
			
		||||
			@extend .w-100
 | 
			
		||||
			@extend .flex-row
 | 
			
		||||
			@extend .p-0
 | 
			
		||||
			@extend .mb-2
 | 
			
		||||
 | 
			
		||||
			flex: initial
 | 
			
		||||
			flex-wrap: wrap
 | 
			
		||||
			max-width: 100%
 | 
			
		||||
@@ -53,8 +63,24 @@
 | 
			
		||||
				@extend .mr-2
 | 
			
		||||
				max-width: 120px
 | 
			
		||||
 | 
			
		||||
			.card-title
 | 
			
		||||
				@extend .text-truncate
 | 
			
		||||
 | 
			
		||||
			&.asset
 | 
			
		||||
				&.free
 | 
			
		||||
					&:after
 | 
			
		||||
						+ribbon
 | 
			
		||||
						content: 'FREE'
 | 
			
		||||
						font-size: .6rem
 | 
			
		||||
						left: -40px
 | 
			
		||||
						padding: 1px 45px
 | 
			
		||||
						right: initial
 | 
			
		||||
						transform: rotate(-45deg)
 | 
			
		||||
 | 
			
		||||
		.card-body
 | 
			
		||||
			@extend .overflow-hidden
 | 
			
		||||
			@extend .text-truncate
 | 
			
		||||
			flex-basis: 0
 | 
			
		||||
 | 
			
		||||
.card-padless
 | 
			
		||||
	.card
 | 
			
		||||
@@ -69,7 +95,7 @@
 | 
			
		||||
		.card-img-top
 | 
			
		||||
			opacity: .9
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
$card-progress-height: 5px
 | 
			
		||||
.card.asset
 | 
			
		||||
	color: $color-text
 | 
			
		||||
 | 
			
		||||
@@ -91,27 +117,41 @@
 | 
			
		||||
			font-size: $font-size-xs
 | 
			
		||||
 | 
			
		||||
	.card-img-top
 | 
			
		||||
		background-color: $color-background
 | 
			
		||||
		background-size: cover
 | 
			
		||||
		background-position: center
 | 
			
		||||
		background-size: cover
 | 
			
		||||
		object-fit: cover
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
	$card-progress-height: 5px
 | 
			
		||||
	.progress
 | 
			
		||||
		height: $card-progress-height
 | 
			
		||||
		position: absolute
 | 
			
		||||
		top: -$card-progress-height
 | 
			
		||||
		bottom: 0
 | 
			
		||||
		width: 100%
 | 
			
		||||
		z-index: 1
 | 
			
		||||
 | 
			
		||||
.card-thumbnail
 | 
			
		||||
	@extend .embed-responsive
 | 
			
		||||
	background-color: rgba($dark, .2)
 | 
			
		||||
	border-top-left-radius: $card-inner-border-radius
 | 
			
		||||
	border-top-right-radius: $card-inner-border-radius
 | 
			
		||||
	color: $dark
 | 
			
		||||
 | 
			
		||||
	&:before
 | 
			
		||||
		padding-top: 56.25%
 | 
			
		||||
 | 
			
		||||
.card-img-top
 | 
			
		||||
	&.card-icon
 | 
			
		||||
		display: flex
 | 
			
		||||
		align-items: center
 | 
			
		||||
		justify-content: center
 | 
			
		||||
		font-size: 2em
 | 
			
		||||
	@extend .align-items-center
 | 
			
		||||
	@extend .d-flex
 | 
			
		||||
	@extend .h-100
 | 
			
		||||
	@extend .position-absolute
 | 
			
		||||
	bottom: 0
 | 
			
		||||
	left: 50% !important
 | 
			
		||||
	top: 0
 | 
			
		||||
	transform: translateX(-50%)
 | 
			
		||||
	width: auto !important
 | 
			
		||||
 | 
			
		||||
		i
 | 
			
		||||
			opacity: .2
 | 
			
		||||
	i
 | 
			
		||||
		font-size: 3em
 | 
			
		||||
		opacity: .2
 | 
			
		||||
 | 
			
		||||
/* Tiny label for cards. e.g. 'WATCHED' on videos. */
 | 
			
		||||
.card-label
 | 
			
		||||
@@ -121,11 +161,16 @@
 | 
			
		||||
	display: block
 | 
			
		||||
	font-size: $font-size-xxs
 | 
			
		||||
	left: 5px
 | 
			
		||||
	top: -27px // enough to be above the progress-bar
 | 
			
		||||
	bottom: $card-progress-height + 3px // enough to be above the progress-bar
 | 
			
		||||
	position: absolute
 | 
			
		||||
	padding: 1px 5px
 | 
			
		||||
	z-index: 1
 | 
			
		||||
 | 
			
		||||
.card-label
 | 
			
		||||
    &.right
 | 
			
		||||
        right: 5px
 | 
			
		||||
        left: auto
 | 
			
		||||
 | 
			
		||||
.card
 | 
			
		||||
	&.active
 | 
			
		||||
		.card-title
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,6 @@
 | 
			
		||||
// 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
 | 
			
		||||
@@ -20,14 +19,15 @@ ul.dropdown-menu
 | 
			
		||||
		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
 | 
			
		||||
// When not in mobile, open menus on mouse hover .dropdown in the navbar.
 | 
			
		||||
body:not(.is-mobile)
 | 
			
		||||
	nav .dropdown:not(.quick-search):hover
 | 
			
		||||
		ul.dropdown-menu
 | 
			
		||||
			display: block
 | 
			
		||||
 | 
			
		||||
nav .dropdown.large:hover
 | 
			
		||||
	.dropdown-menu
 | 
			
		||||
		@extend .d-flex
 | 
			
		||||
	nav .dropdown.large:hover
 | 
			
		||||
		.dropdown-menu
 | 
			
		||||
			@extend .d-flex
 | 
			
		||||
 | 
			
		||||
.dropdown.large.show
 | 
			
		||||
	@extend .d-flex
 | 
			
		||||
 
 | 
			
		||||
@@ -34,7 +34,7 @@
 | 
			
		||||
				margin-right: 10px
 | 
			
		||||
 | 
			
		||||
		.fieldlist-action-button
 | 
			
		||||
			+button($color-success, 3px)
 | 
			
		||||
			+button($color-success, $btn-border-radius)
 | 
			
		||||
			margin: 0 0 0 10px
 | 
			
		||||
			padding: 5px 10px
 | 
			
		||||
			text-transform: initial
 | 
			
		||||
 
 | 
			
		||||
@@ -3,11 +3,17 @@
 | 
			
		||||
	@extend .d-flex
 | 
			
		||||
	@extend .mb-0
 | 
			
		||||
	@extend .rounded-0
 | 
			
		||||
	background-size: cover
 | 
			
		||||
	background:
 | 
			
		||||
		position: center
 | 
			
		||||
		repeat: no-repeat
 | 
			
		||||
		size: cover
 | 
			
		||||
	margin-bottom: 0
 | 
			
		||||
	padding-top: 10em
 | 
			
		||||
	padding-bottom: 10em
 | 
			
		||||
	position: relative
 | 
			
		||||
	word-break: break-word
 | 
			
		||||
 | 
			
		||||
	+media-sm
 | 
			
		||||
		padding-top: 10em
 | 
			
		||||
		padding-bottom: 10em
 | 
			
		||||
 | 
			
		||||
	&:after
 | 
			
		||||
		background-color: rgba(black, .5)
 | 
			
		||||
@@ -42,3 +48,15 @@
 | 
			
		||||
 | 
			
		||||
	&:hover
 | 
			
		||||
		text-decoration: none
 | 
			
		||||
 | 
			
		||||
	.display-4
 | 
			
		||||
		font-size: $h2-font-size
 | 
			
		||||
 | 
			
		||||
		+media-sm
 | 
			
		||||
			font-size: $display4-size
 | 
			
		||||
 | 
			
		||||
	.display-4
 | 
			
		||||
		font-size: $h2-font-size
 | 
			
		||||
 | 
			
		||||
		+media-sm
 | 
			
		||||
			font-size: $display4-size
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,16 @@
 | 
			
		||||
/* Top level navigation bar. */
 | 
			
		||||
.navbar
 | 
			
		||||
	box-shadow: inset 0 -2px  $color-background
 | 
			
		||||
	box-shadow: 0 2px  $color-background
 | 
			
		||||
 | 
			
		||||
.navbar-dark
 | 
			
		||||
	@extend .bg-dark
 | 
			
		||||
	box-shadow: 0 2px $gray-700
 | 
			
		||||
 | 
			
		||||
	.nav-link, .text-muted
 | 
			
		||||
		color: $gray-400 !important
 | 
			
		||||
 | 
			
		||||
	.nav-main .nav-link
 | 
			
		||||
		@extend .text-dark
 | 
			
		||||
 | 
			
		||||
.nav
 | 
			
		||||
	border: none
 | 
			
		||||
@@ -119,22 +129,22 @@
 | 
			
		||||
	&: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
 | 
			
		||||
		margin-bottom: 2px
 | 
			
		||||
		transition: color 150ms ease-in-out
 | 
			
		||||
 | 
			
		||||
		span, i
 | 
			
		||||
			position: relative
 | 
			
		||||
			top: 2px
 | 
			
		||||
 | 
			
		||||
		&:after
 | 
			
		||||
			background-color: transparent
 | 
			
		||||
			bottom: 0
 | 
			
		||||
@@ -155,6 +165,7 @@ $nav-secondary-bar-size: -2px
 | 
			
		||||
			background-image: linear-gradient(to right, $primary-accent 70%, $primary)
 | 
			
		||||
			height: 2px
 | 
			
		||||
			width: 100%
 | 
			
		||||
			bottom: -2px
 | 
			
		||||
 | 
			
		||||
		span
 | 
			
		||||
			+active-gradient
 | 
			
		||||
@@ -162,6 +173,9 @@ $nav-secondary-bar-size: -2px
 | 
			
		||||
		i
 | 
			
		||||
			color: $primary-accent
 | 
			
		||||
 | 
			
		||||
	.nav-link.active
 | 
			
		||||
		font-weight: bold
 | 
			
		||||
 | 
			
		||||
	&.nav-secondary-vertical
 | 
			
		||||
		align-items: flex-start
 | 
			
		||||
		flex-direction: column
 | 
			
		||||
@@ -175,17 +189,21 @@ $nav-secondary-bar-size: -2px
 | 
			
		||||
			&:hover,
 | 
			
		||||
			&.active
 | 
			
		||||
				color: $primary
 | 
			
		||||
				font-weight: initial
 | 
			
		||||
 | 
			
		||||
				@extend .bg-white
 | 
			
		||||
 | 
			
		||||
				&:after
 | 
			
		||||
					background-image: linear-gradient($primary-accent 70%, $primary)
 | 
			
		||||
					height: 100%
 | 
			
		||||
					left: initial
 | 
			
		||||
					left: 0
 | 
			
		||||
					top: 0
 | 
			
		||||
					width: 3px
 | 
			
		||||
 | 
			
		||||
// Big navigation dropdown.
 | 
			
		||||
.nav-main
 | 
			
		||||
	min-width: initial
 | 
			
		||||
 | 
			
		||||
	.nav-secondary
 | 
			
		||||
		.nav-link
 | 
			
		||||
			@extend .pr-5
 | 
			
		||||
@@ -197,6 +215,39 @@ $nav-secondary-bar-size: -2px
 | 
			
		||||
				i, span
 | 
			
		||||
					+active-gradient
 | 
			
		||||
 | 
			
		||||
body.is-mobile
 | 
			
		||||
	.nav-main, .dropdown-menu-tab
 | 
			
		||||
		@extend .position-fixed
 | 
			
		||||
		@extend .w-100
 | 
			
		||||
		bottom: 0
 | 
			
		||||
		font-size: 1.05rem
 | 
			
		||||
		left: 0
 | 
			
		||||
		overflow-y: auto
 | 
			
		||||
		right: 0
 | 
			
		||||
		top: 40px
 | 
			
		||||
		width: 100%
 | 
			
		||||
		z-index: 9999
 | 
			
		||||
		transition: all 150ms ease-in-out
 | 
			
		||||
 | 
			
		||||
		.nav
 | 
			
		||||
			@extend .w-100
 | 
			
		||||
 | 
			
		||||
		.nav-link
 | 
			
		||||
			@extend .py-3
 | 
			
		||||
 | 
			
		||||
			i
 | 
			
		||||
				@extend .pl-2
 | 
			
		||||
				@extend .pr-5
 | 
			
		||||
 | 
			
		||||
	.dropdown-menu-tab
 | 
			
		||||
		@extend .bg-white
 | 
			
		||||
		@extend .w-100
 | 
			
		||||
 | 
			
		||||
		&.show
 | 
			
		||||
			box-shadow: 0 0 25px rgba(black, .3)
 | 
			
		||||
			left: 75px
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
.navbar-overlay
 | 
			
		||||
	+media-lg
 | 
			
		||||
		display: block
 | 
			
		||||
@@ -215,13 +266,6 @@ $nav-secondary-bar-size: -2px
 | 
			
		||||
		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%
 | 
			
		||||
@@ -247,3 +291,18 @@ nav.navbar
 | 
			
		||||
 | 
			
		||||
.navbar+.page-content
 | 
			
		||||
	padding-top: $nav-link-height
 | 
			
		||||
 | 
			
		||||
body.has-overlay
 | 
			
		||||
	nav.navbar
 | 
			
		||||
		background-color: $white !important
 | 
			
		||||
		box-shadow: none !important
 | 
			
		||||
		padding: 10px
 | 
			
		||||
		transition: padding-top $short-transition ease-in-out, padding-bottom $short-transition ease-in-out
 | 
			
		||||
 | 
			
		||||
	.nav-secondary
 | 
			
		||||
		&:not(.keep-when-overlay)
 | 
			
		||||
			opacity: 0
 | 
			
		||||
			transition: opacity $short-transition, visibility $short-transition
 | 
			
		||||
			visibility: hidden
 | 
			
		||||
			display: none !important
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										6
									
								
								src/styles/components/_placeholder.sass
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/styles/components/_placeholder.sass
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
			
		||||
.placeholder
 | 
			
		||||
    +pulse-75
 | 
			
		||||
 | 
			
		||||
    &.replaced // added before replaced
 | 
			
		||||
        opacity: 0
 | 
			
		||||
        transition: 250ms 
 | 
			
		||||
@@ -1,87 +1,70 @@
 | 
			
		||||
#search-overlay
 | 
			
		||||
	position: absolute
 | 
			
		||||
	position: fixed
 | 
			
		||||
	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
 | 
			
		||||
	overflow-y: scroll
 | 
			
		||||
	z-index: $zindex-sticky + 1
 | 
			
		||||
	transition: visibility $long-transition, opacity $long-transition
 | 
			
		||||
 | 
			
		||||
	&.active
 | 
			
		||||
	&.show
 | 
			
		||||
		opacity: 1
 | 
			
		||||
		visibility: visible
 | 
			
		||||
		background-color: rgba($color-background-nav, .7)
 | 
			
		||||
		background-color: rgba($body-bg, .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
 | 
			
		||||
	.qs-result
 | 
			
		||||
		max-width: 80vw
 | 
			
		||||
 | 
			
		||||
	.search-icon
 | 
			
		||||
		position: absolute
 | 
			
		||||
		top: 4px
 | 
			
		||||
		left: 10px
 | 
			
		||||
		cursor: pointer
 | 
			
		||||
#qs-toggle
 | 
			
		||||
	opacity: 1
 | 
			
		||||
	visibility: visible
 | 
			
		||||
 | 
			
		||||
		&:after
 | 
			
		||||
			@extend .tooltip-inner
 | 
			
		||||
.quick-search
 | 
			
		||||
	opacity: 0
 | 
			
		||||
	transition: opacity $short-transition
 | 
			
		||||
	visibility: hidden
 | 
			
		||||
 | 
			
		||||
			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
 | 
			
		||||
.quick-search.show
 | 
			
		||||
	opacity: 1
 | 
			
		||||
	visibility: visible
 | 
			
		||||
 | 
			
		||||
		&:hover
 | 
			
		||||
			&:after
 | 
			
		||||
				opacity: 1
 | 
			
		||||
				top: 35px
 | 
			
		||||
.qs-input
 | 
			
		||||
	&.show input
 | 
			
		||||
		width: 50vw
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
	#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
 | 
			
		||||
	input
 | 
			
		||||
		border-style: solid
 | 
			
		||||
		border-radius: $border-radius
 | 
			
		||||
		height: $input-height-inner
 | 
			
		||||
		padding: $input-padding-y 25px $input-padding-y $input-padding-x
 | 
			
		||||
		width: auto
 | 
			
		||||
 | 
			
		||||
		&:focus
 | 
			
		||||
			box-shadow: none
 | 
			
		||||
			border: none
 | 
			
		||||
			&+i+select
 | 
			
		||||
				border-color: $color-primary
 | 
			
		||||
 | 
			
		||||
		&::placeholder
 | 
			
		||||
			color: rgba($color-text, .5)
 | 
			
		||||
			transition: color 150ms ease-in-out
 | 
			
		||||
		&.multi-scope // has a select next to it
 | 
			
		||||
			border-right: none
 | 
			
		||||
			border-bottom-right-radius: 0
 | 
			
		||||
			border-top-right-radius: 0
 | 
			
		||||
 | 
			
		||||
		&:hover
 | 
			
		||||
			&::placeholder
 | 
			
		||||
				color: rgba($color-text, .6)
 | 
			
		||||
	select
 | 
			
		||||
		border-width: 2px
 | 
			
		||||
		border-left: none
 | 
			
		||||
		border-bottom-right-radius: $border-radius
 | 
			
		||||
		border-top-right-radius: $border-radius
 | 
			
		||||
		height: $input-height-inner
 | 
			
		||||
		margin-left: 5px
 | 
			
		||||
		padding: $input-padding-y $input-padding-x
 | 
			
		||||
 | 
			
		||||
		&:focus, option
 | 
			
		||||
			// background-color: $dark
 | 
			
		||||
			// color: $light
 | 
			
		||||
 | 
			
		||||
	.qs-busy-symbol
 | 
			
		||||
		margin-left: -2em
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										25
									
								
								src/styles/components/_timeline.sass
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/styles/components/_timeline.sass
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,25 @@
 | 
			
		||||
.timeline
 | 
			
		||||
    .group
 | 
			
		||||
        opacity: 0
 | 
			
		||||
        animation: fade-in 500ms forwards
 | 
			
		||||
 | 
			
		||||
    .group-date
 | 
			
		||||
        color: rgba($color-text, .4)
 | 
			
		||||
 | 
			
		||||
    .group-title
 | 
			
		||||
        @extend .border-bottom
 | 
			
		||||
        @extend .bg-white
 | 
			
		||||
        @extend .text-uppercase
 | 
			
		||||
        @extend .font-weight-bold
 | 
			
		||||
        a
 | 
			
		||||
            color: $color-text
 | 
			
		||||
 | 
			
		||||
body.homepage
 | 
			
		||||
    .timeline
 | 
			
		||||
        .sticky-top
 | 
			
		||||
            top: 2.5rem
 | 
			
		||||
 | 
			
		||||
body.is-mobile
 | 
			
		||||
        .timeline
 | 
			
		||||
            .js-asset-list
 | 
			
		||||
                @extend .card-deck-vertical
 | 
			
		||||
@@ -57,6 +57,7 @@
 | 
			
		||||
@import "components/shortcode"
 | 
			
		||||
@import "components/statusbar"
 | 
			
		||||
@import "components/search"
 | 
			
		||||
@import "components/timeline"
 | 
			
		||||
 | 
			
		||||
@import "components/flyout"
 | 
			
		||||
@import "components/forms"
 | 
			
		||||
 
 | 
			
		||||
@@ -123,7 +123,6 @@ $tree-color-highlight-background-text: $primary
 | 
			
		||||
 | 
			
		||||
			/* Selected item */
 | 
			
		||||
			&.jstree-clicked
 | 
			
		||||
				background-color: $tree-color-highlight-background !important
 | 
			
		||||
				color: $tree-color-highlight-background-text !important
 | 
			
		||||
				font-weight: bold
 | 
			
		||||
 | 
			
		||||
@@ -137,16 +136,12 @@ $tree-color-highlight-background-text: $primary
 | 
			
		||||
 | 
			
		||||
				/* hover an active item */
 | 
			
		||||
				&.jstree-hovered
 | 
			
		||||
					background-color: lighten($tree-color-highlight-background, 10%) !important
 | 
			
		||||
					box-shadow: none
 | 
			
		||||
					color: $tree-color-highlight-background-text !important
 | 
			
		||||
 | 
			
		||||
				&.jstree-hovered .jstree-icon
 | 
			
		||||
					color: $tree-color-highlight-background-text !important
 | 
			
		||||
 | 
			
		||||
			.jstree-hovered
 | 
			
		||||
				background-color: rgba($tree-color-highlight, .1) !important
 | 
			
		||||
 | 
			
		||||
	.jstree-leaf .jstree-clicked
 | 
			
		||||
		width: 100% !important
 | 
			
		||||
 | 
			
		||||
@@ -193,63 +188,6 @@ $tree-color-highlight-background-text: $primary
 | 
			
		||||
					&:after
 | 
			
		||||
						display: none !important
 | 
			
		||||
 | 
			
		||||
	&.blog
 | 
			
		||||
		.jstree-anchor
 | 
			
		||||
			padding: 6px 6px 6px 12px
 | 
			
		||||
 | 
			
		||||
			&:hover
 | 
			
		||||
				color: $tree-color-highlight
 | 
			
		||||
 | 
			
		||||
			&.post
 | 
			
		||||
				border-bottom: thin solid $color-background-dark
 | 
			
		||||
 | 
			
		||||
			&.jstree-clicked
 | 
			
		||||
				&.post
 | 
			
		||||
					background-color: transparent !important
 | 
			
		||||
 | 
			
		||||
					&:after
 | 
			
		||||
						top: 8px
 | 
			
		||||
						color: $tree-color-highlight !important
 | 
			
		||||
 | 
			
		||||
					.tree-item-info
 | 
			
		||||
						color: $color-text
 | 
			
		||||
 | 
			
		||||
					.tree-item-title
 | 
			
		||||
						color: $tree-color-highlight
 | 
			
		||||
 | 
			
		||||
		.tree-item
 | 
			
		||||
			line-height: initial
 | 
			
		||||
			padding-right: 10px
 | 
			
		||||
 | 
			
		||||
			&-title
 | 
			
		||||
				font-size: 1.2em
 | 
			
		||||
				overflow: initial
 | 
			
		||||
				text-overflow: initial
 | 
			
		||||
				white-space: normal
 | 
			
		||||
 | 
			
		||||
			&-info
 | 
			
		||||
				color: $color-text-dark-secondary
 | 
			
		||||
				display: block
 | 
			
		||||
				font-size: .8em
 | 
			
		||||
				padding: 5px
 | 
			
		||||
 | 
			
		||||
			&-thumbnail
 | 
			
		||||
				align-items: center
 | 
			
		||||
				background-color: $color-background
 | 
			
		||||
				border-radius: 3px
 | 
			
		||||
				color: $color-text-dark-secondary
 | 
			
		||||
				display: flex
 | 
			
		||||
				float: left
 | 
			
		||||
				height: 70px
 | 
			
		||||
				justify-content: center
 | 
			
		||||
				margin: 0 10px 0 -5px
 | 
			
		||||
				width: 70px
 | 
			
		||||
 | 
			
		||||
				img
 | 
			
		||||
					height: 70px
 | 
			
		||||
					width: 70px
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
.jstree-loading
 | 
			
		||||
	padding: 5px
 | 
			
		||||
	color: $color-text-light-secondary
 | 
			
		||||
@@ -276,11 +214,9 @@ $tree-color-highlight-background-text: $primary
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
a.jstree-anchor.jstree-clicked+ul li a.jstree-anchor.jstree-clicked
 | 
			
		||||
	background-color: rgba($tree-color-highlight-background, .8) !important
 | 
			
		||||
	color: $tree-color-highlight-background-text !important
 | 
			
		||||
 | 
			
		||||
a.jstree-anchor.jstree-clicked+ul li a.jstree-anchor.jstree-clicked+ul li a.jstree-anchor.jstree-clicked
 | 
			
		||||
	background-color: rgba($tree-color-highlight-background, .8) !important
 | 
			
		||||
	color: $tree-color-highlight-background-text !important
 | 
			
		||||
 | 
			
		||||
i.jstree-icon.jstree-ocl
 | 
			
		||||
 
 | 
			
		||||
@@ -1,21 +1,54 @@
 | 
			
		||||
@import base
 | 
			
		||||
@import _comments
 | 
			
		||||
// Bootstrap variables and utilities.
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/functions"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/variables"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/mixins"
 | 
			
		||||
 | 
			
		||||
// Pillar variables and utilities.
 | 
			
		||||
@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/code"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/grid"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/dropdown"
 | 
			
		||||
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/nav"
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/navbar"
 | 
			
		||||
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/tooltip"
 | 
			
		||||
 | 
			
		||||
@import "../../node_modules/bootstrap/scss/utilities"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
// Pillar components.
 | 
			
		||||
@import "apps_base"
 | 
			
		||||
 | 
			
		||||
@import "components/navbar"
 | 
			
		||||
@import "components/dropdown"
 | 
			
		||||
@import "components/footer"
 | 
			
		||||
@import "components/shortcode"
 | 
			
		||||
 | 
			
		||||
@import "components/flyout"
 | 
			
		||||
@import "components/buttons"
 | 
			
		||||
@import "components/popover"
 | 
			
		||||
@import "components/tooltip"
 | 
			
		||||
@import "components/checkbox"
 | 
			
		||||
@import "components/overlay"
 | 
			
		||||
@import "components/card"
 | 
			
		||||
@import "components/search"
 | 
			
		||||
 | 
			
		||||
@import "comments"
 | 
			
		||||
@import "notifications"
 | 
			
		||||
 | 
			
		||||
$color-theatre-background: #222
 | 
			
		||||
$color-theatre-background-light: lighten($color-theatre-background, 5%)
 | 
			
		||||
$color-theatre-background-dark: darken($color-theatre-background, 5%)
 | 
			
		||||
 | 
			
		||||
$theatre-width: 350px
 | 
			
		||||
 | 
			
		||||
body.theatre
 | 
			
		||||
	background-color: $color-theatre-background
 | 
			
		||||
	nav.navbar
 | 
			
		||||
		+media-lg
 | 
			
		||||
			background-color: $color-background-nav
 | 
			
		||||
			background-image: none
 | 
			
		||||
		a.navbar-item.info
 | 
			
		||||
			font-size: 1.4em
 | 
			
		||||
 | 
			
		||||
	.page-content
 | 
			
		||||
		position: absolute
 | 
			
		||||
		top: 0
 | 
			
		||||
@@ -32,15 +65,7 @@ body.theatre
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
#theatre-container
 | 
			
		||||
	display: flex
 | 
			
		||||
	position: relative
 | 
			
		||||
	height: 100%
 | 
			
		||||
	overflow: hidden
 | 
			
		||||
 | 
			
		||||
	#theatre-media
 | 
			
		||||
		display: flex
 | 
			
		||||
		align-items: center
 | 
			
		||||
		justify-content: center
 | 
			
		||||
		height: 100%
 | 
			
		||||
		width: 100%
 | 
			
		||||
		padding: 25px
 | 
			
		||||
@@ -53,7 +78,6 @@ body.theatre
 | 
			
		||||
 | 
			
		||||
		img
 | 
			
		||||
			display: block
 | 
			
		||||
			border: thin solid $color-theatre-background-light
 | 
			
		||||
			box-shadow: 1px 1px 10px rgba(black, .2)
 | 
			
		||||
			max-width: 100%
 | 
			
		||||
			max-height: 100%
 | 
			
		||||
@@ -135,42 +159,16 @@ body.theatre
 | 
			
		||||
					display: block
 | 
			
		||||
 | 
			
		||||
	#theatre-info
 | 
			
		||||
		min-width: $theatre-width
 | 
			
		||||
		overflow-y: auto
 | 
			
		||||
		position: absolute
 | 
			
		||||
		right: -$theatre-width
 | 
			
		||||
		top: 0
 | 
			
		||||
		transition: right 200ms ease-in-out
 | 
			
		||||
		visibility: hidden
 | 
			
		||||
		width: $theatre-width
 | 
			
		||||
		min-width: $theatre-width
 | 
			
		||||
		height: 100%
 | 
			
		||||
		position: relative
 | 
			
		||||
		top: 0
 | 
			
		||||
		right: -$theatre-width
 | 
			
		||||
		background-color: white
 | 
			
		||||
		border-left: 2px solid $color-background-nav
 | 
			
		||||
		transition: right 200ms ease-in-out
 | 
			
		||||
		position: absolute
 | 
			
		||||
		overflow-y: auto
 | 
			
		||||
 | 
			
		||||
		.theatre-info-header
 | 
			
		||||
			border-bottom: thin solid $color-background
 | 
			
		||||
			padding-bottom: 10px
 | 
			
		||||
 | 
			
		||||
			.theatre-info-title
 | 
			
		||||
				padding: 20px 10px 5px 20px
 | 
			
		||||
				font:
 | 
			
		||||
					size: 1.2em
 | 
			
		||||
					weight: 500
 | 
			
		||||
			.theatre-info-user,
 | 
			
		||||
			.theatre-info-date
 | 
			
		||||
				display: inline-block
 | 
			
		||||
				padding: 0 0 0 20px
 | 
			
		||||
				font-size: .9em
 | 
			
		||||
				color: $color-text-dark-secondary
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		ul.theatre-info-details
 | 
			
		||||
			padding: 10px 20px 0 20px
 | 
			
		||||
			margin: 0
 | 
			
		||||
			list-style: none
 | 
			
		||||
			color: $color-text-dark-primary
 | 
			
		||||
 | 
			
		||||
			li
 | 
			
		||||
				display: flex
 | 
			
		||||
				padding: 2px 0
 | 
			
		||||
 
 | 
			
		||||
@@ -6,7 +6,7 @@
 | 
			
		||||
| {% if node_type_name == 'group' %}
 | 
			
		||||
| {% set node_type_name = 'folder' %}
 | 
			
		||||
| {% endif %}
 | 
			
		||||
li(class="button-{{ node_type['name'] }}")
 | 
			
		||||
li
 | 
			
		||||
	a.dropdown-item(
 | 
			
		||||
		class="item_add_node",
 | 
			
		||||
		href="#",
 | 
			
		||||
 
 | 
			
		||||
@@ -2,27 +2,19 @@
 | 
			
		||||
 | 
			
		||||
| {% set node_type = asset.properties.content_type if asset.properties.content_type else asset.node_type %}
 | 
			
		||||
 | 
			
		||||
a.card.asset.card-image-fade.pr-0.mx-0.mb-2(
 | 
			
		||||
	class="js-item-open {% if asset.permissions.world %}free{% endif %}",
 | 
			
		||||
a.card.asset.card-image-fade.mb-2(
 | 
			
		||||
	class="js-item-open {% if asset.permissions.world and not current_user.has_cap('subscriber') %}free{% endif %}",
 | 
			
		||||
	data-node_id="{{ asset._id }}",
 | 
			
		||||
	title="{{ asset.name }}",
 | 
			
		||||
	href='{{ url_for_node(node=asset) }}')
 | 
			
		||||
	.embed-responsive.embed-responsive-16by9
 | 
			
		||||
	.card-thumbnail
 | 
			
		||||
		| {% if asset.picture %}
 | 
			
		||||
		.card-img-top.embed-responsive-item(style="background-image: url({{ asset.picture.thumbnail('m', api=api) }})")
 | 
			
		||||
		img.card-img-top(src="{{ asset.picture.thumbnail('m', api=api) }}", alt="{{ asset.name }}")
 | 
			
		||||
		| {% else %}
 | 
			
		||||
		.card-img-top.card-icon.embed-responsive-item
 | 
			
		||||
		.card-img-top.card-icon
 | 
			
		||||
			i(class="pi-{{ node_type }}")
 | 
			
		||||
		| {% endif %}
 | 
			
		||||
 | 
			
		||||
	.card-body.py-2.d-flex.flex-column
 | 
			
		||||
		.card-title.mb-1.font-weight-bold
 | 
			
		||||
			| {{ asset.name }}
 | 
			
		||||
 | 
			
		||||
		ul.card-text.list-unstyled.d-flex.text-black-50.mt-auto
 | 
			
		||||
			li.pr-2 {{ node_type | undertitle }}
 | 
			
		||||
			li {{ asset._created | pretty_date }}
 | 
			
		||||
 | 
			
		||||
		| {% if asset.properties.content_type == 'video' %}
 | 
			
		||||
 | 
			
		||||
		| {% set view_progress = current_user.nodes.view_progress %}
 | 
			
		||||
@@ -45,6 +37,28 @@ a.card.asset.card-image-fade.pr-0.mx-0.mb-2(
 | 
			
		||||
		.card-label WATCHED
 | 
			
		||||
		| {% endif %}
 | 
			
		||||
		| {% endif %} {# endif progress #}
 | 
			
		||||
		| {% if asset.properties.duration_seconds %}
 | 
			
		||||
		.card-label.right {{ asset.properties.duration_seconds | pretty_duration }}
 | 
			
		||||
		| {% endif %}
 | 
			
		||||
		| {% endif %} {# endif video #}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
	.card-body.py-2.d-flex.flex-column.text-truncate
 | 
			
		||||
		.card-title.mb-1.font-weight-bold.text-truncate
 | 
			
		||||
			| {{ asset.name | hide_none }}
 | 
			
		||||
 | 
			
		||||
		ul.card-text.list-unstyled.d-flex.text-black-50.mt-auto.mb-0.text-truncate
 | 
			
		||||
			| {% if node_type %}
 | 
			
		||||
			li.pr-2.font-weight-bold {{ node_type | undertitle }}
 | 
			
		||||
			| {% endif %}
 | 
			
		||||
			| {% if asset.project.name %}
 | 
			
		||||
			li.pr-2.text-truncate {{ asset.project.name }}
 | 
			
		||||
			| {% endif %}
 | 
			
		||||
			| {% if asset.user.full_name %}
 | 
			
		||||
			li.pr-2.text-truncate {{ asset.user.full_name }}
 | 
			
		||||
			| {% endif %}
 | 
			
		||||
			| {% if asset._created %}
 | 
			
		||||
			li.text-truncate {{ asset._created | pretty_date }}
 | 
			
		||||
			| {% endif %}
 | 
			
		||||
 | 
			
		||||
| {% endmacro %}
 | 
			
		||||
 
 | 
			
		||||
@@ -36,7 +36,7 @@ li.dropdown
 | 
			
		||||
 | 
			
		||||
| {% else %}
 | 
			
		||||
 | 
			
		||||
li.pt-1.pr-1
 | 
			
		||||
li.pr-1
 | 
			
		||||
	a.btn.btn-sm.btn-outline-primary.px-3(
 | 
			
		||||
		href="{{ url_for('users.login') }}")
 | 
			
		||||
		| Log In
 | 
			
		||||
 
 | 
			
		||||
@@ -18,6 +18,8 @@ mixin jumbotron(title, text, image, url)
 | 
			
		||||
						if text
 | 
			
		||||
							.lead
 | 
			
		||||
								=text
 | 
			
		||||
								if block
 | 
			
		||||
									block
 | 
			
		||||
	else
 | 
			
		||||
		.jumbotron.text-white(style='background-image: url(' + image + ');')&attributes(attributes)
 | 
			
		||||
			.container
 | 
			
		||||
@@ -29,6 +31,8 @@ mixin jumbotron(title, text, image, url)
 | 
			
		||||
						if text
 | 
			
		||||
							.lead
 | 
			
		||||
								=text
 | 
			
		||||
								if block
 | 
			
		||||
									block
 | 
			
		||||
 | 
			
		||||
// {# Secondary navigation.
 | 
			
		||||
// e.g. Workshops, Courses. #}
 | 
			
		||||
@@ -52,8 +56,6 @@ mixin card-deck(max_columns)
 | 
			
		||||
	.card-deck.card-padless.card-deck-responsive(class="card-" + max_columns + "-columns")&attributes(attributes)
 | 
			
		||||
		if block
 | 
			
		||||
			block
 | 
			
		||||
		else
 | 
			
		||||
			.p-3 No items.
 | 
			
		||||
 | 
			
		||||
// {#
 | 
			
		||||
// Passes all attributes to the card.
 | 
			
		||||
@@ -71,3 +73,15 @@ mixin list-asset(name, url, image, type, date)
 | 
			
		||||
	if block
 | 
			
		||||
		block
 | 
			
		||||
 | 
			
		||||
// used together with timeline.js
 | 
			
		||||
mixin timeline(projectid, sortdirection)
 | 
			
		||||
	section.timeline.placeholder(
 | 
			
		||||
		data-project-id=projectid,
 | 
			
		||||
		data-sort-dir=sortdirection,
 | 
			
		||||
	)
 | 
			
		||||
		// TODO: Make nicer reuseable placeholder
 | 
			
		||||
		.h3.text-center.text-secondary.p-5.border-bottom
 | 
			
		||||
			i.pi-spin.spin
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -108,8 +108,6 @@ script(type="text/javascript").
 | 
			
		||||
					break
 | 
			
		||||
				default:
 | 
			
		||||
					if (event.keyCode==27) hidePageOverlay();
 | 
			
		||||
					if (event.keyCode==37) navigateTree(true);
 | 
			
		||||
					if (event.keyCode==39) navigateTree();
 | 
			
		||||
					break;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 
 | 
			
		||||
@@ -23,7 +23,7 @@ section.node-preview.video
 | 
			
		||||
 | 
			
		||||
| {% block node_download %}
 | 
			
		||||
| {% if node.file_variations %}
 | 
			
		||||
button.btn.btn-sm.btn-outline-primary.dropdown-toggle.px-3(
 | 
			
		||||
button.btn.btn-outline-primary.dropdown-toggle.px-3(
 | 
			
		||||
	type="button",
 | 
			
		||||
	data-toggle="dropdown",
 | 
			
		||||
	aria-haspopup="true",
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
#theatre-media
 | 
			
		||||
#theatre-media.d-flex.justify-content-center.align-items-center.bg-dark
 | 
			
		||||
	img(src="{{ node.picture.thumbnail('h', api=api) }}", onmousedown="return false")
 | 
			
		||||
 | 
			
		||||
	ul#theatre-tools
 | 
			
		||||
@@ -18,19 +18,21 @@
 | 
			
		||||
				i.pi-download
 | 
			
		||||
		| {% endif %}
 | 
			
		||||
 | 
			
		||||
#theatre-info
 | 
			
		||||
	.theatre-info-header
 | 
			
		||||
		.theatre-info-title {{ node.name }}
 | 
			
		||||
		.theatre-info-user {{ node.user.full_name }}
 | 
			
		||||
		.theatre-info-date {{ node._created | pretty_date_time }}
 | 
			
		||||
#theatre-info.bg-white.h-100
 | 
			
		||||
	h5.p-3 {{ node.name }}
 | 
			
		||||
	small.d-flex.text-secondary.pl-3
 | 
			
		||||
		span.font-weight-bold {{ node.user.full_name }}
 | 
			
		||||
		span.px-3 {{ node._created | pretty_date_time }}
 | 
			
		||||
 | 
			
		||||
	ul.theatre-info-details
 | 
			
		||||
	ul.theatre-info-details.border-bottom.mb-3.p-3.list-unstyled
 | 
			
		||||
		li
 | 
			
		||||
			span Type
 | 
			
		||||
			span {{ node.file.content_type }}
 | 
			
		||||
		| {% if node.file.width %}
 | 
			
		||||
		li
 | 
			
		||||
			span Dimensions
 | 
			
		||||
			span {{ node.file.width }} <small>x</small> {{ node.file.height }}
 | 
			
		||||
		| {% endif %}
 | 
			
		||||
		li
 | 
			
		||||
			span Size
 | 
			
		||||
			span {{ node.file.length | filesizeformat }}
 | 
			
		||||
@@ -41,21 +43,22 @@
 | 
			
		||||
		| {% endif %}
 | 
			
		||||
 | 
			
		||||
	#comments-embed
 | 
			
		||||
		.comments-list-loading
 | 
			
		||||
			i.pi-spin
 | 
			
		||||
 | 
			
		||||
include ../_scripts
 | 
			
		||||
 | 
			
		||||
script.
 | 
			
		||||
	$(function () {
 | 
			
		||||
 | 
			
		||||
		var file_width = {{ node.file.width }};
 | 
			
		||||
		var file_height = {{ node.file.height }};
 | 
			
		||||
		var file_width = '{{ node.file.width }}';
 | 
			
		||||
		var file_height = '{{ node.file.height }}';
 | 
			
		||||
		var theatre_media = document.getElementById('theatre-media');
 | 
			
		||||
		var $theatre_media = $(theatre_media);
 | 
			
		||||
 | 
			
		||||
		function canZoom() {
 | 
			
		||||
			return theatre_media.scrollWidth < file_width ||
 | 
			
		||||
			// If there is no width/height defined, let's just let it zoom.
 | 
			
		||||
			// It might just be a non-image asset, like a file.
 | 
			
		||||
			return file_width == 'None' ||
 | 
			
		||||
					theatre_media.scrollWidth < file_width ||
 | 
			
		||||
					theatre_media.scrollHeight < file_height;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@@ -90,7 +93,7 @@ script.
 | 
			
		||||
			theatreZoom();
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		$('ul.nav.navbar-nav a.navbar-item.info').on('click', function (e) {
 | 
			
		||||
		$('.js-toggle-info').on('click', function (e) {
 | 
			
		||||
			e.preventDefault();
 | 
			
		||||
			$('#theatre-container').toggleClass('with-info');
 | 
			
		||||
		});
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,17 @@
 | 
			
		||||
| {% extends 'layout.html' %}
 | 
			
		||||
| {% import 'nodes/custom/blog/_macros.html' as blogmacros %}
 | 
			
		||||
| {% from 'projects/_macros.html' import render_secondary_navigation %}
 | 
			
		||||
| {% from '_macros/_navigation.html' import navigation_homepage, navigation_project %}
 | 
			
		||||
 | 
			
		||||
| {% set title = 'blog' %}
 | 
			
		||||
 | 
			
		||||
| {% block page_title %}Blog{% endblock%}
 | 
			
		||||
 | 
			
		||||
| {% block navigation_tabs %}
 | 
			
		||||
| {{ render_secondary_navigation(project, navigation_links, title) }}
 | 
			
		||||
| {% if project.url == 'blender-cloud' %}
 | 
			
		||||
| {{ navigation_homepage(title) }}
 | 
			
		||||
| {% else %}
 | 
			
		||||
| {{ navigation_project(project, navigation_links, title) }}
 | 
			
		||||
| {% endif %}
 | 
			
		||||
| {% endblock navigation_tabs %}
 | 
			
		||||
 | 
			
		||||
| {% block body %}
 | 
			
		||||
 
 | 
			
		||||
@@ -6,17 +6,14 @@
 | 
			
		||||
 | 
			
		||||
	.comment-avatar
 | 
			
		||||
		img(src="{{ comment._user.email | gravatar }}", alt="{{ comment._user.full_name }}")
 | 
			
		||||
 | 
			
		||||
		.comment-badges
 | 
			
		||||
			| {{ comment._user.badges_html|safe }}
 | 
			
		||||
	.comment-content
 | 
			
		||||
		.comment-body
 | 
			
		||||
			p.comment-author {{ comment._user.full_name }}
 | 
			
		||||
			//- TODO(Pablo): due to the broad styling done on the .comment-content class the
 | 
			
		||||
			//- styling for the badges that I put in _project.sass isn't applied properly here.
 | 
			
		||||
			| {{ comment._user.badges_html|safe }}
 | 
			
		||||
 | 
			
		||||
			span {{comment.properties | markdowned('content') }}
 | 
			
		||||
 | 
			
		||||
		// TODO: Markdown preview when editing
 | 
			
		||||
		| {# TODO(Pablo): Markdown preview when editing #}
 | 
			
		||||
 | 
			
		||||
		.comment-meta
 | 
			
		||||
			.comment-rating(
 | 
			
		||||
 
 | 
			
		||||
@@ -16,13 +16,14 @@ include ../../../mixins/components
 | 
			
		||||
 | 
			
		||||
		.d-flex.justify-content-end.mb-2
 | 
			
		||||
			button.btn.btn-sm.btn-outline-secondary(
 | 
			
		||||
				id="asset_list_toogle_{{node._id}}",
 | 
			
		||||
				class="js-btn-browsetoggle",
 | 
			
		||||
				title="Toggle between list/grid view",
 | 
			
		||||
				data-toggle="tooltip",
 | 
			
		||||
				data-placement="top")
 | 
			
		||||
				i.pi-list
 | 
			
		||||
 | 
			
		||||
		+card-deck(class="px-2")
 | 
			
		||||
		+card-deck(id="asset_list_{{node._id}}",class="pl-4")
 | 
			
		||||
			| {% for child in children %}
 | 
			
		||||
			| {{ asset_list_item(child, current_user) }}
 | 
			
		||||
			| {% endfor %}
 | 
			
		||||
@@ -63,13 +64,13 @@ include ../../../mixins/components
 | 
			
		||||
 | 
			
		||||
		// Browse type: icon or list
 | 
			
		||||
		function projectBrowseTypeIcon() {
 | 
			
		||||
			$(".card-deck").removeClass('card-deck-vertical');
 | 
			
		||||
			$(".js-btn-browsetoggle").html('<i class="pi-list"></i> List View');
 | 
			
		||||
			$("#asset_list_{{node._id}}").removeClass('card-deck-vertical');
 | 
			
		||||
			$("#asset_list_toogle_{{node._id}}").html('<i class="pi-list"></i> List View');
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		function projectBrowseTypeList() {
 | 
			
		||||
			$(".card-deck").addClass('card-deck-vertical');
 | 
			
		||||
			$(".js-btn-browsetoggle").html('<i class="pi-layout"></i> Grid View');
 | 
			
		||||
			$("#asset_list_{{node._id}}").addClass('card-deck-vertical');
 | 
			
		||||
			$("#asset_list_toogle_{{node._id}}").html('<i class="pi-layout"></i> Grid View');
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		function projectBrowseTypeCheck(){
 | 
			
		||||
@@ -109,7 +110,7 @@ include ../../../mixins/components
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		$('.js-btn-browsetoggle').on('click', function (e) {
 | 
			
		||||
		$("#asset_list_toogle_{{node._id}}").on('click', function (e) {
 | 
			
		||||
			e.preventDefault();
 | 
			
		||||
			projectBrowseToggle();
 | 
			
		||||
		});
 | 
			
		||||
 
 | 
			
		||||
@@ -15,7 +15,7 @@
 | 
			
		||||
		span.texture-info-files {{ children|length }} item{% if children|length != 1 %}s{% endif %}
 | 
			
		||||
	| {% endif %}
 | 
			
		||||
 | 
			
		||||
	section.node-children.group.texture
 | 
			
		||||
	section.node-children.group.texture.px-3
 | 
			
		||||
 | 
			
		||||
		| {% for child in children %}
 | 
			
		||||
		a.list-node-children-container(
 | 
			
		||||
@@ -49,7 +49,7 @@
 | 
			
		||||
 | 
			
		||||
					| {% if child.permissions.world %}
 | 
			
		||||
					.list-node-children-item-ribbon
 | 
			
		||||
						span free
 | 
			
		||||
						span FREE
 | 
			
		||||
					| {% endif %}
 | 
			
		||||
 | 
			
		||||
					| {% if child.node_type == 'texture' %}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,14 @@
 | 
			
		||||
| {% extends 'projects/landing.html' %}
 | 
			
		||||
include ../../../mixins/components
 | 
			
		||||
 | 
			
		||||
| {% set title = node.properties.url %}
 | 
			
		||||
 | 
			
		||||
| {% block body %}
 | 
			
		||||
.expand-image-links.imgs-fluid
 | 
			
		||||
	| {% if node.picture %}
 | 
			
		||||
	+jumbotron(
 | 
			
		||||
		"{{ node.name }}",
 | 
			
		||||
		"{{ node._created | pretty_date }}{% if node.user.full_name %} · {{ node.user.full_name }}{% endif %}",
 | 
			
		||||
		null,
 | 
			
		||||
		"{{ node.picture.thumbnail('h', api=api) }}",
 | 
			
		||||
		"{{ node.url }}")
 | 
			
		||||
	| {% endif %}
 | 
			
		||||
@@ -14,10 +16,8 @@ include ../../../mixins/components
 | 
			
		||||
.container.pb-5
 | 
			
		||||
	.row
 | 
			
		||||
		.col-8.mx-auto
 | 
			
		||||
			h2.pt-5.pb-3.text-center {{node.name}}
 | 
			
		||||
 | 
			
		||||
			| {% if node.description %}
 | 
			
		||||
			.node-details-description
 | 
			
		||||
			.node-details-description.pt-5
 | 
			
		||||
				| {{ node | markdowned('description') }}
 | 
			
		||||
			| {% endif %}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,9 @@
 | 
			
		||||
| {% block body %}
 | 
			
		||||
 | 
			
		||||
#node-container.texture
 | 
			
		||||
	#node-overlay
 | 
			
		||||
 | 
			
		||||
	.texture-title#node-title
 | 
			
		||||
		| {{node.name}}
 | 
			
		||||
	section.px-4
 | 
			
		||||
		h4.pt-4.mb-3(class="js-texture-title")
 | 
			
		||||
			| {{node.name}}
 | 
			
		||||
 | 
			
		||||
	| {% if node.properties.license_type %}
 | 
			
		||||
	| {% if node.properties.license_notes %}
 | 
			
		||||
@@ -24,15 +23,6 @@
 | 
			
		||||
	| {% endif %}
 | 
			
		||||
	| {% endif %}
 | 
			
		||||
 | 
			
		||||
	| {% if node.permissions.world %}
 | 
			
		||||
	.texture-license.public(
 | 
			
		||||
		data-toggle="tooltip",
 | 
			
		||||
		data-placement="bottom",
 | 
			
		||||
		title="Anybody can download. Share it!")
 | 
			
		||||
		i.pi-lock-open
 | 
			
		||||
		span Public
 | 
			
		||||
	| {% endif %}
 | 
			
		||||
 | 
			
		||||
	ul.node-row.texture-info
 | 
			
		||||
		| {% if node.properties.files %}
 | 
			
		||||
		li
 | 
			
		||||
@@ -44,9 +34,20 @@
 | 
			
		||||
			i.pi-puzzle
 | 
			
		||||
			| {% if not node.properties.is_tileable %}Not {% endif %}Seamless
 | 
			
		||||
 | 
			
		||||
		li.ml-auto
 | 
			
		||||
 | 
			
		||||
		| {% if node.permissions.world %}
 | 
			
		||||
		li.text-success(
 | 
			
		||||
			data-toggle="tooltip",
 | 
			
		||||
			data-placement="left",
 | 
			
		||||
			title="Anybody can download. Share it!")
 | 
			
		||||
			i.pi-lock-open
 | 
			
		||||
			span Public
 | 
			
		||||
		| {% endif %}
 | 
			
		||||
 | 
			
		||||
		| {# Display publishing status only to editors #}
 | 
			
		||||
		| {% if node.has_method('PUT') %}
 | 
			
		||||
		li.status(
 | 
			
		||||
		li(
 | 
			
		||||
			class="{{ node.properties.status }}",
 | 
			
		||||
			title="Status")
 | 
			
		||||
			| Status: #[strong {{ node.properties.status | undertitle }}]
 | 
			
		||||
@@ -68,37 +69,32 @@
 | 
			
		||||
 | 
			
		||||
		section.node-details-container.texture
 | 
			
		||||
 | 
			
		||||
			.node-details-header
 | 
			
		||||
				.node-title {{map_type}}
 | 
			
		||||
			.px-3.d-flex.flex-column.h-100
 | 
			
		||||
				h5 {{ map_type }}
 | 
			
		||||
 | 
			
		||||
			.node-details-attributes
 | 
			
		||||
				span.sizes
 | 
			
		||||
					span.x
 | 
			
		||||
						| Width:
 | 
			
		||||
						strong {{ f.file.width }}
 | 
			
		||||
					span.y
 | 
			
		||||
						| Height:
 | 
			
		||||
						strong {{ f.file.height }}
 | 
			
		||||
				span.length
 | 
			
		||||
					| {{ f.file.length | filesizeformat }}
 | 
			
		||||
				span.content_type
 | 
			
		||||
					| {{ f.file.content_type }}
 | 
			
		||||
				.d-flex.flex-column.text-black-50.h-100
 | 
			
		||||
					span
 | 
			
		||||
						| #[strong(title='Width') {{ f.file.width }}] x #[strong(title='Height') {{ f.file.height }}]
 | 
			
		||||
 | 
			
		||||
			.node-details-meta
 | 
			
		||||
				ul.node-details-meta-list
 | 
			
		||||
					span.mt-auto {{ f.file.length | filesizeformat }}
 | 
			
		||||
					span.text-uppercase.pt-1
 | 
			
		||||
						| {{ f.file.content_type }}
 | 
			
		||||
 | 
			
		||||
				ul.list-unstyled.mt-auto.pt-2
 | 
			
		||||
					li.node-details-meta-list-item.texture.download
 | 
			
		||||
						| {% if f.file.link %}
 | 
			
		||||
						a(href="{{ f.file.link }}",,
 | 
			
		||||
						a(href="{{ f.file.link }}",
 | 
			
		||||
							title="Download texture",
 | 
			
		||||
							download="{{ f.file.filename }}")
 | 
			
		||||
							button.btn.btn-outline-secondary(type="button")
 | 
			
		||||
							button.btn.btn-sm.btn-outline-primary.px-3.btn-block(type="button")
 | 
			
		||||
								i.pi-download
 | 
			
		||||
								|  Download
 | 
			
		||||
						| {% else %}
 | 
			
		||||
						button.btn.btn-outline-secondary.disabled.sorry(type="button")
 | 
			
		||||
						button.btn.btn-sm.btn-outline-primary.px-3.btn-block.disabled(type="button")
 | 
			
		||||
							i.pi-lock
 | 
			
		||||
							| Download
 | 
			
		||||
						| {% endif %}
 | 
			
		||||
 | 
			
		||||
	| {% else %}
 | 
			
		||||
	section.node-row
 | 
			
		||||
		section.node-details-container.texture
 | 
			
		||||
@@ -116,9 +112,9 @@ script.
 | 
			
		||||
	// Generate GA pageview
 | 
			
		||||
	ga('send', 'pageview', location.pathname);
 | 
			
		||||
 | 
			
		||||
	var str = $('.texture-title').text();
 | 
			
		||||
	var str = $('.js-texture-title').text();
 | 
			
		||||
	var to_replace = /_color|_bump|_specular|_normal|_translucency|_emission|_alpha|_tileable|.jpg|.png/g;
 | 
			
		||||
	$('.texture-title').text(str.replace(to_replace,'').replace(/_/g,' '));
 | 
			
		||||
	$('.js-texture-title').text(str.replace(to_replace,'').replace(/_/g,' '));
 | 
			
		||||
 | 
			
		||||
	$('.node-preview-thumbnail').each(function(i){
 | 
			
		||||
		$(this).closest('.node-preview').css({'height' : $(this).width() / $(this).data('aspect_ratio')});
 | 
			
		||||
@@ -177,12 +173,6 @@ script.
 | 
			
		||||
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	$('.sorry').click(function() {
 | 
			
		||||
		$.get('/403', function(data) {
 | 
			
		||||
			$('#node-overlay').html(data).show().addClass('active');
 | 
			
		||||
		})
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	$('#node-overlay').click(function(){
 | 
			
		||||
		$(this).removeClass('active').hide().html();
 | 
			
		||||
	});
 | 
			
		||||
 
 | 
			
		||||
@@ -101,41 +101,37 @@ script(type="text/javascript").
 | 
			
		||||
		var page_title = 'Edit: {{ node.name }} - {{ project.name }} — Blender Cloud';
 | 
			
		||||
		DocumentTitleAPI.set_page_title(page_title);
 | 
			
		||||
 | 
			
		||||
		/* Build the markdown preview when typing in textarea */
 | 
			
		||||
		var convert = new Markdown.getSanitizingConverter();
 | 
			
		||||
		Markdown.Extra.init(convert);
 | 
			
		||||
		convert = convert.makeHtml;
 | 
			
		||||
		var $contentField = $('.form-group.description textarea'),
 | 
			
		||||
				$contentPreview = $('<div class="node-edit-form-md-preview" />').insertAfter($contentField);
 | 
			
		||||
 | 
			
		||||
		var $textarea = $('.form-group.description textarea'),
 | 
			
		||||
				$loader = $('<div class="md-preview-loading"><i class="pi-spin spin"></i></div>').insertAfter($textarea),
 | 
			
		||||
				$preview = $('<div class="node-edit-form-md-preview" />').insertAfter($loader);
 | 
			
		||||
		function parseDescriptionContent(content) {
 | 
			
		||||
 | 
			
		||||
		$loader.hide();
 | 
			
		||||
			$.ajax({
 | 
			
		||||
				url: "{{ url_for('nodes.preview_markdown')}}",
 | 
			
		||||
				type: 'post',
 | 
			
		||||
				data: {content: content},
 | 
			
		||||
				headers: {"X-CSRFToken": csrf_token},
 | 
			
		||||
				headers: {},
 | 
			
		||||
				dataType: 'json'
 | 
			
		||||
			})
 | 
			
		||||
			.done(function (data) {
 | 
			
		||||
				$contentPreview.html(data.content);
 | 
			
		||||
			})
 | 
			
		||||
			.fail(function (err) {
 | 
			
		||||
				toastr.error(xhrErrorResponseMessage(err), 'Parsing failed');
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Delay function to not start converting heavy posts immediately
 | 
			
		||||
		var delay = (function(){
 | 
			
		||||
			var timer = 0;
 | 
			
		||||
			return function(callback, ms){
 | 
			
		||||
				clearTimeout (timer);
 | 
			
		||||
				timer = setTimeout(callback, ms);
 | 
			
		||||
			};
 | 
			
		||||
		})();
 | 
			
		||||
		var options = {
 | 
			
		||||
			callback: parseDescriptionContent,
 | 
			
		||||
			wait: 750,
 | 
			
		||||
			highlight: false,
 | 
			
		||||
			allowSubmit: false,
 | 
			
		||||
			captureLength: 2
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		$textarea.keyup(function() {
 | 
			
		||||
			/* If there's an iframe (YouTube embed), delay markdown convert 1.5s */
 | 
			
		||||
			if (/iframe/i.test($textarea.val())) {
 | 
			
		||||
				$loader.show();
 | 
			
		||||
		$contentField.typeWatch(options);
 | 
			
		||||
 | 
			
		||||
				delay(function(){
 | 
			
		||||
					// Convert markdown
 | 
			
		||||
					$preview.html(convert($textarea.val()));
 | 
			
		||||
					$loader.hide();
 | 
			
		||||
				}, 1500 );
 | 
			
		||||
			} else {
 | 
			
		||||
				// Convert markdown
 | 
			
		||||
				$preview.html(convert($textarea.val()));
 | 
			
		||||
			}
 | 
			
		||||
		}).trigger('keyup');
 | 
			
		||||
 | 
			
		||||
		$('input, textarea').keypress(function () {
 | 
			
		||||
			// Unused: save status of the page as 'edited'
 | 
			
		||||
@@ -172,7 +168,7 @@ script(type="text/javascript").
 | 
			
		||||
 | 
			
		||||
		/* Let us know started saving */
 | 
			
		||||
		$("li.button-save").addClass('saving');
 | 
			
		||||
		$("li.button-save a#item_save").html('<i class="pi-spin spin"></i> Saving...');
 | 
			
		||||
		$("li.button-save a#item_save").html('<i class="pi-spin pr-2 spin"></i> Saving...');
 | 
			
		||||
 | 
			
		||||
		$.ajax({
 | 
			
		||||
			url: "{{url_for('nodes.edit', node_id=node._id)}}",
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,31 @@
 | 
			
		||||
| {% extends 'layout.html' %}
 | 
			
		||||
| {% from '_macros/_asset_list_item.html' import asset_list_item %}
 | 
			
		||||
| {% from '_macros/_navigation.html' import navigation_homepage, navigation_project %}
 | 
			
		||||
include ../mixins/components
 | 
			
		||||
 | 
			
		||||
| {% if project %}
 | 
			
		||||
| {% set title = 'search-project' %}
 | 
			
		||||
| {% else %}
 | 
			
		||||
| {% set title = 'search' %}
 | 
			
		||||
| {% endif %}
 | 
			
		||||
 | 
			
		||||
| {% block navigation_tabs %}
 | 
			
		||||
| {% if project %}
 | 
			
		||||
| {{ navigation_project(project, navigation_links, title) }}
 | 
			
		||||
| {% else %}
 | 
			
		||||
| {{ navigation_homepage(title) }}
 | 
			
		||||
| {% endif %}
 | 
			
		||||
| {% endblock navigation_tabs %}
 | 
			
		||||
 | 
			
		||||
| {% block navigation_search %}{% endblock navigation_search %}
 | 
			
		||||
 | 
			
		||||
| {% block page_title %}Search{% if project %} {{ project.name }}{% endif %}{% endblock %}
 | 
			
		||||
 | 
			
		||||
| {% block head %}
 | 
			
		||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/videojs-6.2.8.min.js') }}")
 | 
			
		||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/video.min.js') }}")
 | 
			
		||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/videojs-ga-0.4.2.min.js') }}")
 | 
			
		||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/videojs-hotkeys-0.2.20.min.js') }}")
 | 
			
		||||
script(src="{{ url_for('static_pillar', filename='assets/js/video_plugins.min.js') }}")
 | 
			
		||||
| {% endblock %}
 | 
			
		||||
 | 
			
		||||
| {% block og %}
 | 
			
		||||
@@ -31,27 +49,26 @@ script.
 | 
			
		||||
	document.body.dataset["projectId"] = "{{project._id}}";
 | 
			
		||||
| {% endif %}
 | 
			
		||||
 | 
			
		||||
| {% if project %}
 | 
			
		||||
#project_sidebar.bg-white
 | 
			
		||||
	ul.project-tabs.p-0
 | 
			
		||||
		li.tabs-browse(
 | 
			
		||||
			title="Browse",
 | 
			
		||||
			data-toggle="tooltip",
 | 
			
		||||
			data-placement="right")
 | 
			
		||||
			a(href="{{url_for('projects.view', project_url=project.url, _external=True)}}")
 | 
			
		||||
				i.pi-folder
 | 
			
		||||
 | 
			
		||||
		li.tabs-search.active(
 | 
			
		||||
			title="Search",
 | 
			
		||||
			data-toggle="tooltip",
 | 
			
		||||
			data-placement="right")
 | 
			
		||||
			a(href="{{url_for('projects.search', project_url=project.url, _external=True)}}")
 | 
			
		||||
				i.pi-search
 | 
			
		||||
| {% endif %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
#search-container.d-flex(class="{% if project %}search-project{% endif %}")
 | 
			
		||||
	.search-list
 | 
			
		||||
	| {% if project %}
 | 
			
		||||
	#project_sidebar.bg-white
 | 
			
		||||
		ul.project-tabs.p-0
 | 
			
		||||
			li.tabs-browse(
 | 
			
		||||
				title="Browse",
 | 
			
		||||
				data-toggle="tooltip",
 | 
			
		||||
				data-placement="right")
 | 
			
		||||
				a(href="{{url_for('projects.view', project_url=project.url, _external=True)}}")
 | 
			
		||||
					i.pi-folder
 | 
			
		||||
 | 
			
		||||
			li.tabs-search.active(
 | 
			
		||||
				title="Search",
 | 
			
		||||
				data-toggle="tooltip",
 | 
			
		||||
				data-placement="right")
 | 
			
		||||
				a(href="{{url_for('projects.search', project_url=project.url, _external=True)}}")
 | 
			
		||||
					i.pi-search
 | 
			
		||||
	| {% endif %}
 | 
			
		||||
 | 
			
		||||
	.search-settings#search-sidebar.bg-light
 | 
			
		||||
		input.search-field.p-2.bg-white(
 | 
			
		||||
			type="text",
 | 
			
		||||
			name="q",
 | 
			
		||||
@@ -62,13 +79,12 @@ script.
 | 
			
		||||
			placeholder="Search by Title, Type...")
 | 
			
		||||
 | 
			
		||||
		#pagination.mt-3
 | 
			
		||||
 | 
			
		||||
		//- #accordion.panel-group.accordion(role="tablist", aria-multiselectable="true")
 | 
			
		||||
		#facets
 | 
			
		||||
 | 
			
		||||
		#stats.search-list-stats
 | 
			
		||||
 | 
			
		||||
		+card-deck()(id='hits', class="h-100 m-0 pt-3 pr-2 card-deck-vertical")
 | 
			
		||||
	.border-left.search-list
 | 
			
		||||
 | 
			
		||||
		+card-deck()(id='hits', class="m-0 px-2 card-deck-vertical")
 | 
			
		||||
 | 
			
		||||
	#search-details.border-left.search-details
 | 
			
		||||
		#search-error
 | 
			
		||||
@@ -78,54 +94,19 @@ script.
 | 
			
		||||
| {% raw %}
 | 
			
		||||
// Facet template
 | 
			
		||||
script(type="text/template", id="facet-template")
 | 
			
		||||
	.card
 | 
			
		||||
		a(data-toggle='collapse', data-parent='#accordion', href='#filter_{{ facet }}', aria-expanded='true', aria-controls='filter_{{ facet }}')
 | 
			
		||||
			.card-header(role='tab')
 | 
			
		||||
				.card-title {{ title }}
 | 
			
		||||
		.collapse.show(id='filter_{{ facet }}', role='tabpanel', aria-labelledby='headingOne')
 | 
			
		||||
			.card-body
 | 
			
		||||
				| {{#values}}
 | 
			
		||||
				a.facet_link.toggleRefine(
 | 
			
		||||
					class='{{#refined}}refined{{/refined}}',
 | 
			
		||||
					data-facet='{{ facet }}',
 | 
			
		||||
					data-value='{{ value }}',
 | 
			
		||||
					href='#')
 | 
			
		||||
					span
 | 
			
		||||
						| {{ label }}
 | 
			
		||||
						small.facet_count.pull-right {{ count }}
 | 
			
		||||
				| {{/values}}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
// Hit template
 | 
			
		||||
script(type="text/template", id="hit-template")
 | 
			
		||||
	a.card.asset.card-image-fade.pl-0.mx-0.mb-1(
 | 
			
		||||
		data-hit-id='{{ objectID }}',
 | 
			
		||||
		href="/nodes/{{ objectID }}/redir",
 | 
			
		||||
		class="js-search-hit {{#is_free}}free{{/is_free}}")
 | 
			
		||||
		.embed-responsive.embed-responsive-16by9
 | 
			
		||||
			| {{#picture}}
 | 
			
		||||
			.card-img-top.embed-responsive-item(style="background-image: url({{{ picture }}})")
 | 
			
		||||
			| {{/picture}}
 | 
			
		||||
			| {{^picture}}
 | 
			
		||||
			.card-img-top.card-icon.embed-responsive-item
 | 
			
		||||
				| {{#media}}
 | 
			
		||||
				i(class="pi-{{{ media }}}")
 | 
			
		||||
				| {{/media}}
 | 
			
		||||
				| {{^media}}
 | 
			
		||||
				i(class="pi-{{{ node_type }}}")
 | 
			
		||||
				| {{/media}}
 | 
			
		||||
			| {{/picture}}
 | 
			
		||||
		.card-body.py-2.d-flex.flex-column
 | 
			
		||||
			.card-title.mb-1.font-weight-bold
 | 
			
		||||
				| {{ name }}
 | 
			
		||||
 | 
			
		||||
			ul.card-text.list-unstyled.d-flex.text-black-50.mt-auto
 | 
			
		||||
				li.pr-2.project {{ project.name }}
 | 
			
		||||
				| {{#media}}
 | 
			
		||||
				li.pr-2.text-capitalize {{{ media }}}
 | 
			
		||||
				| {{/media}}
 | 
			
		||||
				li.pr-2 {{{ created_at }}}
 | 
			
		||||
 | 
			
		||||
	.card.border-0.p-0.m-2
 | 
			
		||||
		.card-body.p-3.m-0
 | 
			
		||||
			h6.text-muted.facet-title {{ title }}
 | 
			
		||||
			| {{#values}}
 | 
			
		||||
			a.facet_link.toggleRefine(
 | 
			
		||||
				class='{{#refined}}refined{{/refined}}',
 | 
			
		||||
				data-facet='{{ facet }}',
 | 
			
		||||
				data-value='{{ value }}',
 | 
			
		||||
				href='#')
 | 
			
		||||
				span
 | 
			
		||||
					| {{ label }}
 | 
			
		||||
					small.text-black-50.float-right {{ count }}
 | 
			
		||||
			| {{/values}}
 | 
			
		||||
 | 
			
		||||
// Pagination template
 | 
			
		||||
script(type="text/template", id="pagination-template")
 | 
			
		||||
@@ -156,12 +137,12 @@ script.
 | 
			
		||||
			$('#search-hit-container').html(dataHtml);
 | 
			
		||||
		})
 | 
			
		||||
		.done(function(){
 | 
			
		||||
			$('.loader-bar').removeClass('active');
 | 
			
		||||
			loadingBarHide();
 | 
			
		||||
			$('#search-error').hide();
 | 
			
		||||
			$('#search-hit-container').show();
 | 
			
		||||
		})
 | 
			
		||||
		.fail(function(data){
 | 
			
		||||
			$('.loader-bar').removeClass('active');
 | 
			
		||||
			loadingBarHide();
 | 
			
		||||
			$('#search-hit-container').hide();
 | 
			
		||||
			$('#search-error').show().html('Houston!\n\n' + data.status + ' ' + data.statusText);
 | 
			
		||||
		});
 | 
			
		||||
@@ -170,9 +151,10 @@ script.
 | 
			
		||||
	$('body').on('click', '.js-search-hit', function(e){
 | 
			
		||||
		e.preventDefault();
 | 
			
		||||
 | 
			
		||||
		$('.loader-bar').removeClass('active').addClass('active');
 | 
			
		||||
		loadingBarHide();
 | 
			
		||||
		loadingBarShow();
 | 
			
		||||
 | 
			
		||||
		displayNode($(this).data('hit-id'));
 | 
			
		||||
		displayNode($(this).data('node-id'));
 | 
			
		||||
		$('.js-search-hit').removeClass('active');
 | 
			
		||||
		$(this).addClass('active');
 | 
			
		||||
	});
 | 
			
		||||
@@ -210,5 +192,5 @@ script.
 | 
			
		||||
 | 
			
		||||
| {% endblock %}
 | 
			
		||||
 | 
			
		||||
| {% block footer_navigation %}{% endblock %}
 | 
			
		||||
| {% block footer_container %}{% endblock %}
 | 
			
		||||
| {% block footer %}{% endblock %}
 | 
			
		||||
 
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user