128 Commits

Author SHA1 Message Date
d0e12401c0 Introduce support for confidence calculations 2018-11-26 23:44:16 +01:00
411a6f75c5 Change default comments sorting
Comments were sorted by descending creation date. Now they are sorted by
descending confidence and descending creation date.
2018-11-26 19:48:12 +01:00
07821c7f97 Timeline Firefox bug fix: load more not working properly
Firefox failed to redraw the page properly when loading more weeks.
2018-11-23 14:55:58 +01:00
64b4ce3ba9 Minor layout and style adjustments. 2018-11-22 21:52:07 +01:00
72417a9abb Minor layout and style adjustments. 2018-11-22 21:35:27 +01:00
6ae9a5ddeb Quick-Search: Added Quick-search in the topbar
Changed how and what we store in elastic to unify it with how we store
things in mongodb so we can have more generic javascript code
to render the data.

Elastic changes:
  Added:
  Node.project.url

  Altered to store id instead of url
  Node.picture

  Made Post searchable

./manage.py elastic reset_index
./manage.py elastic reindex

Thanks to Pablo and Sybren
2018-11-22 15:31:53 +01:00
a897e201ba Timeline Fix: Attachment in post did not work 2018-11-22 14:39:25 +01:00
3985a00c6f Timeline: Style and layout adjustments 2018-11-21 20:32:27 +01:00
119291f817 Timeline: Remove header and lead from posts.
Headers don't really match with the rest of the listing.
2018-11-21 20:24:12 +01:00
801cda88bf Project View: Labels for sections 2018-11-21 20:23:07 +01:00
fc99713732 Project-Timeline: Introduced timeline on projects
Limited to projects of category assets and film for now.
2018-11-20 16:29:01 +01:00
1d909faf49 CSS: Override margin-bottom for emoji images. 2018-11-16 23:57:00 +01:00
ed35c54361 CSS: Fix alignment on list with custom bullets. 2018-11-16 23:57:00 +01:00
411b15b1a0 Pin versions in package.json
This should lead to predictable results when running ./gulp.
2018-11-16 15:45:46 +01:00
9b85a938f3 Add npm deps: acorn and glob 2018-11-16 14:31:46 +01:00
989a40a7f7 Add missing dependency for transpiling es6 2018-11-16 14:06:50 +01:00
64cc4dc9bf Bug fix: Sharing files failing
Found using sentry
2018-11-16 12:46:30 +01:00
9182188647 CSS: Minor style tweaks to user login.
Don't use hardcoded white color for container-box mixin.
2018-11-16 12:38:40 +01:00
5896f4cfdd CSS: Use generic colors for inputs border colors.
More reliable when theming.
2018-11-16 02:31:13 +01:00
f9a407054d CSS: Fix emoji set as block.
When parent styling set images to be block, emoji should always be inline.
2018-11-15 23:54:16 +01:00
1c46e4c96b CSS: Fix !default setting in config 2018-11-14 02:06:22 +01:00
2990738b5d Lazy Home: Lazy load latest blog posts and assets and group by week and
project.

Javascript tutti.js and timeline.js is needed, and then the following to
init the timeline:

$('.timeline')
    .timeline({
        url: '/api/timeline'
    });

# Javascript Notes:
## ES6 transpile:
* Files in src/scripts/js/es6/common will be transpiled from
modern es6 js to old es5 js, and then added to tutti.js
* Files in src/scripts/js/es6/individual will be transpiled from
modern es6 js to old es5 js to individual module files
## JS Testing
* Added the Jest test framework to write javascript tests.
* `npm test` will run all the javascript tests

Thanks to Sybren for reviewing
2018-11-12 12:57:25 +01:00
e2432f6e9f NPM: Upgrade to Gulp 4
No functional changes. Besides slightly faster thanks to parallel tasks and future proof.
2018-11-10 01:08:30 +01:00
aa63389b4f Remove duplicated file
The file was copy-pasted in api/search.
2018-11-04 11:48:08 +01:00
5075cd5bd0 Introducing Flask Debug Toolbar
Display useful information for debugging.
2018-11-01 02:19:13 +01:00
ceef04455c Video player in project header bug (firefox):
Unable to play video in in project header in firefox.

Reason:
Firefox is missing ResizeObserver, so as a workaround videoJs inserts an
iframe bellow the video and listens to resize events on that. This iframe
lands in front of the video when we use the class ".embed-responsive",
and therefore we can not start the wideo.

Solution:
I could not see any difference in how the page was rendered
with/without this class so I removed it.
2018-10-24 13:34:08 +02:00
c8e62e3610 Loading bar: Introduced two event listeners on window 'pillar:workStart' and 'pillar:workStop' that (de)activates the loading bar.
Reason:
* To decouple code
* Have the loading bar active until whole page stopped working
* Have local loading info

Usage:
$.('.myClass')
   .on('pillar:workStart', function(){
    ... do stuff locally while loading ...
    })
   .on('pillar:workStop', function(){
   ... stop do stuff locally while loading ...
   })

$.('.myClass .mySubClass').trigger('pillar:workStart')
... do stuff ...
$.('.myClass .mySubClass').trigger('pillar:workStop')
2018-10-23 13:57:02 +02:00
ce7cf52d70 Refresh badges every 10 minutes
Now that they are new, they should be snappy!
2018-10-11 10:04:16 +02:00
dc2105fbb8 Enabled badges in comments 2018-10-10 16:55:10 +02:00
71185af880 Added json jinja filter for debugging purposes 2018-10-10 16:55:10 +02:00
041f8914b2 Show badges on user profile page 2018-10-10 16:55:06 +02:00
b4ee5b59bd Sync Blender ID badge as soon as user logs in
This adds a new Blinker signal `user_logged_in` that is only sent when
the user logs in via the web interface (and not on every token
authentication and every API call).
2018-10-10 16:54:58 +02:00
314ce40e71 Send logged-in user in user_authenticated signal 2018-10-10 15:30:35 +02:00
7e941e2299 Added TODOs and removed fetching unused field from MongoDB 2018-10-10 14:40:45 +02:00
53811363ce Search bug fix: Missing video plugins resulted in wrong volume and progress. 2018-10-05 14:37:32 +02:00
51057e4d63 Search bug fix: Grid/List toggle on group nodes also affected the the way search results where presented 2018-10-05 12:37:48 +02:00
a1a48c1941 Elasticsearch: Added documentation on how to set the indexing. 2018-10-05 11:35:02 +02:00
19fdc75e60 Free assets: Assets should not be advertised as free if the user is a logged in subscriber. 2018-10-04 17:44:08 +02:00
879bcffc2b Asset list item: Don't show user.full_name in latest and random assets 2018-10-04 12:30:05 +02:00
6ad12d0098 Video Duration: The duration of a video is now shown on thumbnails and bellow the video player
Asset nodes now have a new field called "properties.duration_seconds". This holds a copy of the duration stored on the referenced video file and stays in sync using eve hooks.

To migrate existing duration times from files to nodes you need to run the following:
./manage.py maintenance reconcile_node_video_duration -ag

There are 2 more maintenance commands to be used to determine if there are any missing durations in either files or nodes:
find_video_files_without_duration
find_video_nodes_without_duration

FFProbe is now used to detect what duration a video file has.

Reviewed by Sybren.
2018-10-03 18:30:40 +02:00
a738cdcad8 Fix and tweaks to theatre mode
* Only show width/height if available (would be None otherwise)
* If image width/height is not available, allow zooming
* Fix styling and cleanup
* Remove footer (reported by Vulp35 on Twitter, thanks!)
2018-10-01 11:56:52 +02:00
199f37c5d7 Tagged Asset: Added metadata
Video duration, Project link and pretty date
2018-09-26 11:29:15 +02:00
4cf93f00f6 Assets: Fix video progress not showing 2018-09-24 13:31:48 +02:00
eaf9235fa9 Fix users listing styling 2018-09-21 17:11:26 +02:00
24ecf36896 CSS: Brighter primary button 2018-09-21 16:51:45 +02:00
86aa494aed CSS: Use 3 cards even on media-xl 2018-09-21 16:25:48 +02:00
5a5b97d362 Introducing Main Dropdown navigation for mobile 2018-09-21 16:13:50 +02:00
831858a336 CSS: Make buttons use bootstraps' variable for roundness 2018-09-21 16:13:50 +02:00
e9d247fe97 Added assertion in test to verify that the asset was deleted 2018-09-21 14:24:37 +02:00
1ddd8525c7 Remove references to node from projects when the node is deleted.
Removes node references  in project fields header_node, nodes_blog, nodes_featured, nodes_latest.
2018-09-21 14:23:47 +02:00
c43941807c Node details: Center only on landing 2018-09-21 12:11:11 +02:00
bbad8eb5c5 Remove unused project macros file
The only macro was render_secondary_navigation, which is in the _navigation.pug
template together with the other Blender Cloud navigation macros.
2018-09-20 16:38:17 +02:00
04f00cdd4f Loading Bar: Utility to turn it on/off 2018-09-20 15:20:29 +02:00
66d9fd0908 Center node-details-description 2018-09-20 12:15:08 +02:00
516ef2ddc7 Navigation: if category is Assets, then call it Libraries 2018-09-20 12:10:35 +02:00
35fb07ee64 Navigation: Move marker on left side
On the right it looks like a scrollbar.
2018-09-20 12:10:09 +02:00
f1d67894dc Rename secondary_navigation to navigation_project 2018-09-20 12:05:46 +02:00
aef2cf8c2d Navigation: Fix notification number 2018-09-19 19:43:49 +02:00
d347ddac2c Navigation: Films -> Open Projects
And show navigation when in the Blog
2018-09-19 19:33:01 +02:00
186ba167f1 Navigation: remove extra 's' for assets project
Such a lame solution. We need better categories.
2018-09-19 19:09:04 +02:00
847e97fe8c Project: remove arrow left/right navigation hotkey 2018-09-19 18:33:53 +02:00
7ace5f4292 Search: use proper navigation
Also remove failing projectBrowseTypeList js
2018-09-19 18:22:27 +02:00
6cb85b06dc Project: Dark navbar for edit project 2018-09-19 18:21:47 +02:00
5c019e8d1c Landing: Set project title as active 2018-09-19 15:50:23 +02:00
7796179021 Navigation: Position icons 2018-09-19 15:42:18 +02:00
26aca917c8 Use correct permission format for gulp-chmod 2018-09-19 14:45:43 +02:00
e262a5c240 Jumbotron: take content if defined in the block 2018-09-19 12:39:18 +02:00
e079ac4da1 CSS adjustments to dropdowns, cards, responsive 2018-09-19 11:33:20 +02:00
83097cf473 Projects: Explore -> Browse 2018-09-18 18:53:55 +02:00
f4ade9cda7 Allow empty content for card-deck component
In cases like the tags groups we want an empty card-deck because its
content is filled up via javascript.
2018-09-18 16:56:08 +02:00
31244a89e5 Merge branch 'master' into production 2018-09-18 15:50:55 +02:00
749c3dbd58 Gulp: Add bootstrap's collapse and alert js to tutti 2018-09-18 15:25:20 +02:00
b1d97e723f Cards: Smaller ribbon for vertical aligned cards 2018-09-18 15:25:20 +02:00
46bdd4f51c Pages: Don't show date and page title
It's already in the jumbotron
2018-09-18 15:25:20 +02:00
93720e226c Badges: don't display them just yet 2018-09-18 15:25:20 +02:00
9a0da126e6 Fix failing tests
Failure was due to a new ‘slug’ key in the link dict.
2018-09-18 15:14:27 +02:00
45672565e9 Card style fixes 2018-09-18 12:53:34 +02:00
3e1273d56c CSS: zoom-in cursor utility 2018-09-18 12:49:06 +02:00
fe86f76617 Search: styling 2018-09-17 19:04:42 +02:00
008d9b8880 Comments: padding 2018-09-17 18:35:04 +02:00
13b606df45 CSS cleanup and use classes for styling 2018-09-17 18:16:42 +02:00
57f5836829 Cleanup and replace custom styles with bootstrap classes. 2018-09-17 17:08:46 +02:00
e40ba69872 Project style adjustments. 2018-09-17 17:07:10 +02:00
0aeae2cabd Navigation: Highlight current page in the navbar 2018-09-17 15:02:54 +02:00
601b94e23a Pages: Set title from page properties url 2018-09-17 15:02:24 +02:00
00c4ec8741 Navigation Links: Pass the slug
So we can style the items by comparing it to the page 'title'.
2018-09-17 15:01:57 +02:00
caee114d48 Posts: Remove unused title and pages 2018-09-17 15:01:23 +02:00
7fccf02e68 Posts: Pass navigation_links
Otherwise pages wont show up when looking at a project blog
2018-09-17 15:00:55 +02:00
1c42e8fd07 Nodes View: Remove unnecessary containers
#node-container and #node-overlay were not used.
2018-09-17 14:26:37 +02:00
77f855be3e Remove jQuery Montage
No longer used since we list assets with a macro.
2018-09-17 14:25:19 +02:00
cede3e75db Remove more Markdown references 2018-09-17 13:47:03 +02:00
02a7014bf4 Cleanup and title-underline utility 2018-09-17 12:54:07 +02:00
04e51a9d3f CSS: Break to large size a bit earlier 2018-09-17 12:53:25 +02:00
d4fd6b5cda Asset Listing: display author name (when available) 2018-09-17 12:52:48 +02:00
2935b442d8 Remove outdated remarkdown_comments management command 2018-09-17 09:14:11 +02:00
567247f3fd Rename hooks.py to eve_hooks.py
Follow naming convention started in Attract and Flamenco.
2018-09-17 09:09:46 +02:00
def52944bf CSS tweaks for embeds, videos and iframe 2018-09-16 23:56:31 +02:00
8753a12dee Tweak unit test to support new embed code 2018-09-16 22:04:22 +02:00
77e3c476f0 Move node hooks into own file 2018-09-16 13:04:12 +02:00
842ddaeab0 Assets: Display similar assets based on tags
Experimental.
2018-09-16 06:29:19 +02:00
85e5cb4f71 Projects: Only display category for public projects 2018-09-16 05:02:52 +02:00
6648f8d074 Minor style adjustments 2018-09-16 05:02:16 +02:00
a5bc36b1cf Jumbotron overlay is now optional.
Just add the jumbotron-overlay class, or jumbotron-overlay-gradient
2018-09-16 04:28:11 +02:00
e56b3ec61f Use Pillar's built-in markdown when editing projects/creating posts. 2018-09-16 04:27:24 +02:00
9624f6bd76 Style pages 2018-09-16 04:05:37 +02:00
4e5a53a19b Option to limit card-deck to a maximum N columns
Only 3 supported for now
2018-09-16 03:42:48 +02:00
fbc7c0fce7 CSS: media breakpoints
from Bootstrap and added a couple more for super big screens
2018-09-16 03:39:54 +02:00
bb483e72aa CSS cleanup (blog, comments) 2018-09-16 03:05:34 +02:00
baf27fa560 Blog: Fix and css cleanup 2018-09-16 02:04:14 +02:00
845ba953cb Make YouTube shortcode embeds responsive
Part of T56813
2018-09-15 22:32:03 +02:00
e5b7905a5c Project: Sort navigation links
See T56813
2018-09-15 22:12:12 +02:00
88c0ef0e7c Blog: fixes and tweaks 2018-09-15 21:32:54 +02:00
f8d992400e Extend attachment shortcode rendering
The previous implementation only supported rendering
attachments within the context of a node or project document.
Now it also supports node.properties. This is a temporary
solution, as noted in the TODO comments.
2018-09-15 19:01:58 +02:00
263d68071e Add view_progress to nodes of type asset 2018-09-15 17:59:30 +02:00
0f7f7d5a66 Profile styling, layout and cleanup. 2018-09-15 16:42:29 +02:00
6b29c70212 Navigation menu: Style see-more items 2018-09-15 06:16:06 +02:00
07670dce96 Fix view type list for folders 2018-09-15 05:50:42 +02:00
fe288b1cc2 Typo 2018-09-15 05:50:10 +02:00
2e9555e160 Layout and style for new global menu. 2018-09-15 05:41:15 +02:00
b0311af6b5 CSS: $primary-accent color and gradient utils 2018-09-15 05:40:29 +02:00
35a22cab4b Fix wrong url 2018-09-14 23:12:02 +02:00
0055633732 Blog: Styling and cleanup 2018-09-14 20:30:04 +02:00
78b186c8e4 Blog: Unify all post viewing in one template
During the years we went from site-wide blog, to project blog, to
post view inside a project, to full one-page post view. This led
to have multiple ways to see the same content.

This commit brings all post related stuff to always use index.pug
(or index_archive if we are looking blasts from the past).
2018-09-14 20:29:44 +02:00
232321cc2c Blog: Cleanup CSS 2018-09-14 17:29:13 +02:00
a6d662b690 Refactor render_secondary_navigation macro
* Use navigation_links instead of pages.
* Use secondary navigation mixin.
* Always include project category.
* Always include Explore tab.

Should be eventually moved to Blender Cloud repo.
2018-09-14 16:58:48 +02:00
32c7ffbc99 Move project-main to Blender Cloud
Also remove calls to project-landing, it is now part of project-main.
It was just a few lines of code not worth having a different CSS file.
2018-09-14 16:56:35 +02:00
cfcc629b61 Update package-lock.json 2018-09-14 13:11:49 +02:00
5e07cfb9b2 Send the request URL to Sentry
Also removed some dead code.
2018-09-05 14:58:34 +02:00
136 changed files with 13716 additions and 3330 deletions

3
.babelrc Normal file
View File

@@ -0,0 +1,3 @@
{
"presets": ["@babel/preset-env"]
}

View File

@@ -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`.

View File

@@ -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
View 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,
};

8122
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -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):

View File

@@ -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')

View File

@@ -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)

View File

@@ -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},

View File

@@ -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},

View File

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

View File

@@ -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']

View File

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

View File

@@ -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']

View File

@@ -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

View File

@@ -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
View 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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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():

View File

@@ -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)

View File

@@ -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

View File

@@ -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']:

View File

@@ -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

View File

@@ -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']

View File

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

View File

@@ -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,

View File

@@ -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)

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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('');
});

View 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);
});
}
}

View 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);
});
}
})

View 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;
}
}

View 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);
}
}

View File

@@ -0,0 +1 @@
export { QuickSearch } from './QuickSearch';

View 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 }

View 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();
});
});
})
});

View File

@@ -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"}')
});
});

View 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))
}
});

View 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);
}
}

View File

@@ -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';
}
}

View 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
};

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -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');
});
})
});

View 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 };

View File

@@ -0,0 +1 @@
export { transformPlaceholder } from './placeholder'

View 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();
})
}

View 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()
);
});
}
})

View File

@@ -0,0 +1,7 @@
export { Timeline } from './Timeline';
// Init timelines on document ready
$(function() {
$(".timeline")
.timeline();
})

View File

@@ -0,0 +1,2 @@
import $ from 'jquery';
global.$ = global.jQuery = $;

View File

@@ -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();
});
});

View File

@@ -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');

View File

@@ -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>'
)
);
}
});
});

View File

@@ -400,11 +400,10 @@ nav.sidebar
left: -19px
z-index: 1
$loader-bar-width: 100px
$loader-bar-height: 2px
.loader-bar
background-color: $color-background
bottom: 0
$loading-bar-width: 100px
$loading-bar-height: 2px
.loading-bar
bottom: -$loading-bar-height
content: ''
display: none
height: 0
@@ -412,28 +411,30 @@ $loader-bar-height: 2px
position: absolute
visibility: hidden
width: 100%
z-index: 20
&:before
animation: none
background-color: $primary
background-image: linear-gradient(to right, $primary-accent, $primary)
content: ''
display: block
height: $loader-bar-height
left: -$loader-bar-width
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%
@@ -453,3 +454,13 @@ $loader-bar-height: 2px
.progress-bar
background-color: $primary
background-image: linear-gradient(to right, $primary-accent, $primary)
.node-details-description
+node-details-description
@include media-breakpoint-up(lg)
max-width: map-get($grid-breakpoints, "md")
@include media-breakpoint-up(xl)
max-width: map-get($grid-breakpoints, "lg")

View File

@@ -1,7 +1,9 @@
$comments-width-max: 710px
.comments-container
max-width: $comments-width-max
position: relative
width: 100%
#comments-reload
text-align: center
@@ -30,7 +32,7 @@ $comments-width-max: 710px
.comment-reply-container
display: flex
position: relative
padding: 15px 0 20px 0
padding: 15px 0
transition: background-color 150ms ease-in-out, padding 150ms ease-in-out, margin 150ms ease-in-out
&.comment-linked
@@ -194,8 +196,6 @@ $comments-width-max: 710px
cursor: pointer
font-family: 'pillar-font'
height: 25px
position: relative
top: 4px
width: 16px
.comment-action-rating.up
@@ -284,10 +284,11 @@ $comments-width-max: 710px
color: $color-success
&.is-replying
box-shadow: inset 5px 0 0 $color-primary
box-shadow: -5px 0 0 $color-primary
@extend .pl-3
&.is-replying+.comment-reply-container
box-shadow: inset 5px 0 0 $color-primary
box-shadow: -5px 0 0 $color-primary
margin-left: 0
padding-left: 55px
@@ -314,9 +315,6 @@ $comments-width-max: 710px
color: $color-success
.comment-reply
&-container
background-color: $color-background
/* Little gravatar icon on the left */
&-avatar
img
@@ -333,7 +331,7 @@ $comments-width-max: 710px
width: 100%
&-field
background-color: $color-background-dark
background-color: $color-background-light
border-radius: 3px
box-shadow: inset 0 0 2px 0 rgba(darken($color-background-dark, 20%), .5)
display: flex
@@ -342,6 +340,7 @@ $comments-width-max: 710px
textarea
+node-details-description
background-color: $color-background-light
border-bottom-right-radius: 0
border-top-right-radius: 0
border: none
@@ -376,7 +375,6 @@ $comments-width-max: 710px
&.filled
textarea
background-color: $color-background-light
border-bottom: thin solid $color-background
&:focus
@@ -501,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

View File

@@ -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,16 +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 !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
@@ -100,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
@@ -155,4 +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
$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

View File

@@ -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

View File

@@ -24,13 +24,16 @@
color: $color-secondary
#notifications-toggle
color: $color-text
cursor: pointer
font-size: 1.5em
position: relative
user-select: none
> i:before
content: '\e815'
font-size: 1.3em
position: relative
top: 2px
&.has-notifications
> i:before
@@ -58,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
@@ -71,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

View File

@@ -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

View File

@@ -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

View File

@@ -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%
@@ -77,6 +84,8 @@ body.workshops
a
color: $primary
i
+active-gradient
a
align-items: center
@@ -92,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
@@ -492,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
@@ -581,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
@@ -649,9 +608,6 @@ section.node-details-container
width: 100%
max-width: 100%
.node-details-description
+node-details-description
.node-details-meta
> ul
align-items: center
@@ -846,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
@@ -1034,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
@@ -1071,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
@@ -1291,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
@@ -1728,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
@@ -1776,7 +1732,7 @@ a.learn-more
box-shadow: 0 5px 35px rgba(black, .2)
color: $color-text-dark-primary
position: absolute
top: 0
top: -$project_header-height
left: 0
right: 0
width: 80%
@@ -1804,7 +1760,7 @@ a.learn-more
&.visible
visibility: visible
opacity: 1
top: $project_header-height
top: 0
.overlay-container
.title
@@ -1861,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
@@ -1886,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
@@ -1973,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
@@ -2022,5 +1978,3 @@ a.learn-more
padding: 5px 35px
text-align: center
.ribbon
+ribbon

View File

@@ -93,9 +93,20 @@ $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-horizontal
.card-deck.card-deck-vertical
.card .embed-responsive
max-width: 80px
@@ -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

View File

@@ -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
@@ -67,131 +66,6 @@
&:hover
background-color: lighten($provider-color-google, 7%)
#settings
#settings-sidebar
+media-xs
width: 100%
+container-box
background-color: $color-background-light
color: $color-text
margin-right: 15px
width: 30%
.settings-content
padding: 0
ul
list-style: none
margin: 0
padding: 0
a
&:hover
text-decoration: none
li
background-color: lighten($color-background, 5%)
li
border-bottom: thin solid $color-background
border-left: thick solid transparent
margin: 0
padding: 25px
transition: all 100ms ease-in-out
i
font-size: 1.1em
padding-right: 15px
.active
li
background-color: lighten($color-background, 5%)
border-left: thick solid $color-info
#settings-container
+media-xs
width: 100%
+container-box
background-color: $color-background-light
width: 70%
.settings-header
background-color: $color-background
border-top-left-radius: 3px
border-top-right-radius: 3px
.settings-title
font:
size: 1.5em
weight: 300
padding: 10px 15px 10px 25px
.settings-content
padding: 25px
.settings-billing-info
font-size: 1.2em
.subscription-active
color: $color-success
padding-bottom: 20px
.subscription-demo
color: $color-info
margin-top: 0
.subscription-missing
color: $color-danger
margin-top: 0
.button-submit
clear: both
display: block
min-width: 200px
margin: 0 auto
+button($color-primary, 3px, true)
#settings-container
#settings-form
width: 100%
.settings-form
align-items: center
display: flex
justify-content: center
.left, .right
padding: 25px 0
.left
width: 60%
float: left
.right
width: 40%
float: right
text-align: center
label
color: $color-text
display: block
.settings-avatar
img
border-radius: 3px
span
display: block
padding: 15px 0
font:
size: .9em
.settings-password
color: $color-text-dark-primary
#user-edit-container
padding: 15px
@@ -212,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

View File

@@ -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
@@ -171,17 +174,25 @@
/* Small but wide: phablets, iPads
** Menu is collapsed, columns stack, no brand */
=media-sm
@media (min-width: #{$screen-tablet}) and (max-width: #{$screen-desktop - 1px})
@include media-breakpoint-up(sm)
@content
/* Tablets portrait.
** Menu is expanded, but columns stack, brand is shown */
=media-md
@media (min-width: #{$screen-desktop})
@include media-breakpoint-up(md)
@content
=media-lg
@media (min-width: #{$screen-lg-desktop})
@include media-breakpoint-up(lg)
@content
=media-xl
@include media-breakpoint-up(xl)
@content
=media-xxl
@include media-breakpoint-up(xxl)
@content
=media-print
@@ -296,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)
@@ -341,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
@@ -375,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
@@ -400,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
@@ -416,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
@@ -443,7 +445,6 @@
ul
margin-bottom: 25px
padding-left: 40px
li
margin-bottom: 7px
@@ -523,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
@@ -562,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
@@ -656,9 +676,15 @@
.cursor-pointer
cursor: pointer
.cursor-zoom-in
cursor: zoom-in
.user-select-none
user-select: none
.pointer-events-none
pointer-events: none
// Bootstrap has .img-fluid, a class to limit the width of an image to 100%.
// .imgs-fluid below is to be applied on a parent container when we can't add
// classes to the images themselves. e.g. the blog.
@@ -669,3 +695,29 @@
.overflow-hidden
overflow: hidden
=text-gradient($color_from, $color_to)
background: linear-gradient(to right, $color_from, $color_to)
background-clip: text
-webkit-background-clip: text
-webkit-text-fill-color: transparent
=active-gradient
+text-gradient($primary-accent, $primary)
&:before
+text-gradient($primary-accent, $primary)
.title-underline
padding-bottom: 5px
position: relative
margin-bottom: 20px
&:before
background-color: $primary
content: ' '
display: block
height: 2px
top: 125%
position: absolute
width: 50px

View File

@@ -15,85 +15,41 @@
@import "../../node_modules/bootstrap/scss/code"
@import "../../node_modules/bootstrap/scss/grid"
@import "../../node_modules/bootstrap/scss/tables"
@import "../../node_modules/bootstrap/scss/forms"
@import "../../node_modules/bootstrap/scss/buttons"
@import "../../node_modules/bootstrap/scss/transitions"
@import "../../node_modules/bootstrap/scss/badge"
@import "../../node_modules/bootstrap/scss/dropdown"
@import "../../node_modules/bootstrap/scss/button-group"
@import "../../node_modules/bootstrap/scss/input-group"
@import "../../node_modules/bootstrap/scss/custom-forms"
@import "../../node_modules/bootstrap/scss/nav"
@import "../../node_modules/bootstrap/scss/navbar"
@import "../../node_modules/bootstrap/scss/card"
@import "../../node_modules/bootstrap/scss/breadcrumb"
@import "../../node_modules/bootstrap/scss/pagination"
@import "../../node_modules/bootstrap/scss/badge"
@import "../../node_modules/bootstrap/scss/jumbotron"
@import "../../node_modules/bootstrap/scss/alert"
@import "../../node_modules/bootstrap/scss/progress"
@import "../../node_modules/bootstrap/scss/media"
@import "../../node_modules/bootstrap/scss/list-group"
@import "../../node_modules/bootstrap/scss/close"
@import "../../node_modules/bootstrap/scss/modal"
@import "../../node_modules/bootstrap/scss/tooltip"
@import "../../node_modules/bootstrap/scss/popover"
@import "../../node_modules/bootstrap/scss/carousel"
@import "../../node_modules/bootstrap/scss/utilities"
@import "../../node_modules/bootstrap/scss/print"
// Pillar components.
@import "apps_base"
@import "components/base"
@import "components/card"
@import "components/jumbotron"
@import "components/alerts"
@import "components/navbar"
@import "components/dropdown"
@import "components/footer"
@import "components/shortcode"
@import "components/statusbar"
@import "components/search"
@import "components/flyout"
@import "components/forms"
@import "components/inputs"
@import "components/buttons"
@import "components/popover"
@import "components/tooltip"
@import "components/checkbox"
@import "components/overlay"
@import "components/card"
@import "components/search"
@import _comments
@import _error
@import _search
@import components/base
@import components/alerts
@import components/navbar
@import components/footer
@import components/shortcode
@import components/statusbar
@import components/search
@import components/flyout
@import components/forms
@import components/inputs
@import components/buttons
@import components/popover
@import components/tooltip
@import components/checkbox
@import components/overlay
#blog_container
+media-xs
flex-direction: column
padding-top: 0
video
max-width: 100%
@import _notifications
#blog_post-edit-form
padding: 20px
@@ -166,7 +122,6 @@
margin-bottom: 15px
border-top: thin solid $color-text-dark-hint
.form-group.description,
.form-group.summary,
.form-group.content
@@ -234,64 +189,10 @@
color: transparent
#blog_post-create-container,
#blog_post-edit-container
padding: 25px
.blog_index-item
.item-picture
position: relative
width: 100%
max-height: 350px
min-height: 200px
height: auto
overflow: hidden
border-top-left-radius: 3px
border-top-right-radius: 3px
+clearfix
img
+position-center-translate
width: 100%
border-top-left-radius: 3px
border-top-right-radius: 3px
+media-xs
min-height: 150px
+media-sm
min-height: 150px
+media-md
min-height: 250px
+media-lg
min-height: 250px
.item-content
+node-details-description
+media-xs
padding:
left: 0
right: 0
img
display: block
margin: 0 auto
.item-meta
color: $color-text-dark-secondary
padding:
left: 25px
right: 25px
+media-xs
padding:
left: 10px
right: 10px
#blog_index-container,
#blog_post-create-container,
#blog_post-edit-container
+container-box
padding: 25px
width: 75%
+media-xs
@@ -306,11 +207,6 @@
+media-lg
width: 100%
.item-picture+.button-back+.button-edit
right: 20px
top: 20px
#blog_post-edit-form
padding: 0
@@ -345,206 +241,3 @@
.form-upload-file-meta
width: initial
#blog_post-edit-title
padding: 0
color: $color-text
font:
size: 1.8em
weight: 300
margin: 0 20px 15px 0
#blog_index-sidebar
width: 25%
padding: 0 15px
+media-xs
width: 100%
clear: both
display: block
margin-top: 25px
+media-sm
width: 40%
+media-md
width: 30%
+media-lg
width: 25%
.button-back
+button($color-info, 6px, true)
display: block
width: 100%
margin: 15px 0 0 0
#blog_post-edit-form
.form-group
.form-control
background-color: white
.blog_index-sidebar,
.blog_project-sidebar
+container-box
background-color: lighten($color-background, 5%)
padding: 20px
.blog_project-card
position: relative
width: 100%
border-radius: 3px
overflow: hidden
background-color: white
color: lighten($color-text, 10%)
box-shadow: 0 0 30px rgba(black, .2)
margin:
top: 0
bottom: 15px
left: auto
right: auto
a.item-header
position: relative
width: 100%
height: 100px
display: block
background-size: 100% 100%
overflow: hidden
.overlay
z-index: 1
width: 100%
height: 100px
@include overlay(transparent, 0%, white, 100%)
img.background
width: 100%
transform: scale(1.4)
.card-thumbnail
position: absolute
z-index: 2
height: 90px
width: 90px
display: block
top: 35px
left: 50%
transform: translateX(-50%)
background-color: white
border-radius: 3px
overflow: hidden
&:hover
img.thumb
opacity: .9
img.thumb
width: 100%
border-radius: 3px
transition: opacity 150ms ease-in-out
+position-center-translate
.item-info
padding: 10px 20px
background-color: white
border-bottom-left-radius: 3px
border-bottom-right-radius: 3px
a.item-title
display: inline-block
width: 100%
padding: 30px 0 15px 0
color: $color-text-dark
text-align: center
font:
size: 1.6em
weight: 300
transition: color 150ms ease-in-out
&:hover
text-decoration: none
color: $color-primary
.blog-archive-navigation
+media-xs
font-size: 1em
max-width: initial
border-bottom: thin solid $color-background-dark
display: flex
font:
size: 1.2em
weight: 300
margin: 0 auto
max-width: 780px
text-align: center
+text-overflow-ellipsis
&:last-child
border: none
a, span
+media-xs
padding: 10px
flex: 1
padding: 25px 15px
span
color: $color-text-dark-secondary
pointer-events: none
// Specific tweaks for blogs in the context of a project.
#project_context
.blog_index-item
+media-xs
margin-left: 0
padding: 0
margin-left: 10px
&.list
margin-left: 35px !important
.item-title,
.item-info
+media-xs
padding-left: 0
padding-left: 25px
#blog_container
.comments-container
+media-sm
margin-left: 10px
margin-left: 30px
.blog-archive-navigation
margin-left: 35px
// Used on the blog.
.comments-compact
.comments-list
border: none
padding: 0 0 15px 0
.comments-container
max-width: 680px
margin: 0 auto
.comment-reply-container
background-color: transparent
.comment-reply-field
textarea, .comment-reply-meta
background-color: $color-background-light
&.filled
.comment-reply-meta
background-color: $color-success
.comment-reply-form
+media-xs
padding:
left: 0

View File

@@ -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

View File

@@ -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)

View File

@@ -1,34 +1,59 @@
.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-3
margin: 0 0 20px 0
$card-width-percentage: 30%
flex: 1 0 $card-width-percentage
max-width: $card-width-percentage
+media-xs
flex: 1 0 50%
max-width: 50%
$card-width-percentage: 100%
flex: 1 0 $card-width-percentage
max-width: $card-width-percentage
+media-sm
flex: 1 0 33%
max-width: 33%
$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 25%
max-width: 25%
$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 20%
max-width: 20%
$card-width-percentage: 30%
flex: 1 0 $card-width-percentage
max-width: $card-width-percentage
margin-right: 100% / ($card-width-percentage / 1%)
&.card-deck-horizontal
+media-xl
$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
$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%
.card-img-top
@@ -38,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
@@ -54,7 +95,7 @@
.card-img-top
opacity: .9
$card-progress-height: 5px
.card.asset
color: $color-text
@@ -76,28 +117,43 @@
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
background-color: rgba($black, .5)
border-radius: 3px
@@ -105,11 +161,16 @@
display: block
font-size: $font-size-xxs
left: 5px
top: -25px
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

View File

@@ -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,7 +19,26 @@ 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
.dropdown.large.show
@extend .d-flex
.dropdown-menu.show
@extend .d-flex
.dropdown-menu-tab
display: none
min-width: 100px
&.show // .dropdown-menu-tab.show
@extend .d-flex

View File

@@ -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

View File

@@ -1,29 +1,62 @@
// Mainly overrides bootstrap jumbotron settings
.jumbotron
background-size: cover
border-radius: 0
@extend .d-flex
@extend .mb-0
@extend .rounded-0
background:
position: center
repeat: no-repeat
size: cover
margin-bottom: 0
padding-top: 10em
padding-bottom: 10em
position: relative
word-break: break-word
+media-sm
padding-top: 10em
padding-bottom: 10em
&:after
background-color: rgba(black, .5)
bottom: 0
content: ''
display: none
left: 0
position: absolute
right: 0
top: 0
visibility: hidden
// Black-transparent gradient from left to right to better read the overlay text.
&.jumbotron-overlay
position: relative
&:after
background-image: linear-gradient(45deg, rgba(black, .5) 25%, transparent 50%)
bottom: 0
content: ''
left: 0
position: absolute
right: 0
top: 0
*
z-index: 1
&:after
display: block
visibility: visible
&.jumbotron-overlay-gradient
*
z-index: 1
&:after
background-color: transparent
background-image: linear-gradient(45deg, rgba(black, .5) 25%, transparent 50%)
display: block
visibility: visible
h2, p
text-shadow: 1px 1px rgba(black, .2), 1px 1px 25px rgba(black, .5)
&:hover
text-decoration: none
.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

View File

@@ -1,9 +1,18 @@
/* Top level navigation bar. */
.navbar
box-shadow: inset 0 -2px $color-background
box-shadow: 0 2px $color-background
.navbar,
nav.sidebar
.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
color: $color-text-dark-secondary
padding: 0
@@ -19,29 +28,6 @@ nav.sidebar
margin: 0
width: 100%
.navbar-item
align-items: center
display: flex
user-select: none
color: inherit
+media-sm
padding-left: 10px
padding-right: 10px
&:hover, &:focus
color: $primary
background-color: transparent
box-shadow: inset 0 -3px 0 $primary
text-decoration: none
&:focus
box-shadow: inset 0 -3px 0 $primary
&.active
color: $primary
box-shadow: inset 0 -3px 0 $primary
li
user-select: none
position: relative
@@ -80,71 +66,115 @@ nav.sidebar
i
+position-center-translate
.dropdown
min-width: 50px // navbar avatar size
.dropdown
.navbar-item
&:hover
box-shadow: none // Remove the blue underline usually on navbar, from dropdown items.
span.fa-stack
position: absolute
top: 50%
left: 50%
transform: translate(-50%, -50%)
ul.dropdown-menu
li
a
white-space: nowrap
ul.dropdown-menu
li
a
white-space: nowrap
.subitem // e.g. "Not Sintel? Log out"
font-size: .8em
text-transform: initial
&:hover
box-shadow: none // removes underline
i
width: 30px
.subitem // e.g. "Not Sintel? Log out"
font-size: .8em
text-transform: initial
&.subscription-status
a, a:hover
color: $white
i
width: 30px
&.none
background-color: $color-danger
&.subscription-status
a, a:hover
color: $white
&.subscriber
background-color: $color-success
&.none
background-color: $color-danger
&.demo
background-color: $color-info
&.subscriber
background-color: $color-success
span.info
display: block
&.demo
background-color: $color-info
span.info
span.renew
display: block
font-size: .9em
span.renew
display: block
font-size: .9em
.nav-link
@extend .d-flex
.nav-title
white-space: nowrap
.navbar-item
align-items: center
display: flex
user-select: none
color: inherit
+media-sm
padding-left: 10px
padding-right: 10px
&:hover, &:focus
color: $primary
background-color: transparent
box-shadow: inset 0 -3px 0 $primary
text-decoration: none
&:focus
box-shadow: inset 0 -3px 0 $primary
/* Secondary navigation. */
$nav-secondary-bar-size: -2px
.nav-secondary
align-items: center
box-shadow: inset 0 $nav-secondary-bar-size 0 0 $color-background
.nav-link
box-shadow: inset 0 $nav-secondary-bar-size 0 0 $color-background
color: $color-text
cursor: pointer
transition: box-shadow 150ms ease-in-out
margin-bottom: 2px
transition: color 150ms ease-in-out
span, i
position: relative
top: 2px
&:after
background-color: transparent
bottom: 0
content: ''
height: 2px
position: absolute
right: 0
left: 0
width: 0
transition: width 150ms ease-in-out
.nav-link:hover,
.nav-link.active,
.nav-item.dropdown.show .nav-link
.nav-item.dropdown.show > .nav-link
// Blue bar on the bottom.
box-shadow: inset 0 $nav-secondary-bar-size 0 0 $primary
&:after
background-color: $primary-accent
background-image: linear-gradient(to right, $primary-accent 70%, $primary)
height: 2px
width: 100%
bottom: -2px
span
+active-gradient
i
color: $primary
color: $primary-accent
.nav-link.active
font-weight: bold
&.nav-secondary-vertical
align-items: flex-start
@@ -156,18 +186,66 @@ $nav-secondary-bar-size: -2px
// Blue bar on the side.
.nav-link
box-shadow: inset 0 -1px 0 0 $color-background, inset -1px 0 0 0 $color-background
&:hover,
&.active
box-shadow: inset 0 -1px 0 0 $color-background, inset ($nav-secondary-bar-size * 1.5) 0 0 0 $primary
color: $primary
font-weight: initial
&.nav-main
@extend .bg-white
&:after
background-image: linear-gradient($primary-accent 70%, $primary)
height: 100%
left: 0
top: 0
width: 3px
// Big navigation dropdown.
.nav-main
min-width: initial
.nav-secondary
.nav-link
color: $color-text-dark-secondary
@extend .pr-5
box-shadow: none
&:hover
color: $body-color
&.nav-see-more
color: $primary
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
@@ -188,22 +266,6 @@ $nav-secondary-bar-size: -2px
background-color: $color-background-nav
text-shadow: none
.navbar-brand
color: inherit
padding: 0 0 0 3px
position: relative
top: -2px
&:hover
color: $primary
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%
@@ -229,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

View File

@@ -0,0 +1,6 @@
.placeholder
+pulse-75
&.replaced // added before replaced
opacity: 0
transition: 250ms

View File

@@ -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

View 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

View File

@@ -57,6 +57,7 @@
@import "components/shortcode"
@import "components/statusbar"
@import "components/search"
@import "components/timeline"
@import "components/flyout"
@import "components/forms"

View File

@@ -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

View File

@@ -1,82 +0,0 @@
// Bootstrap variables and utilities.
@import "../../node_modules/bootstrap/scss/functions"
@import "../../node_modules/bootstrap/scss/variables"
@import "../../node_modules/bootstrap/scss/mixins"
@import _config
@import _utils
// Bootstrap components.
@import "../../node_modules/bootstrap/scss/root"
@import "../../node_modules/bootstrap/scss/reboot"
@import "../../node_modules/bootstrap/scss/type"
@import "../../node_modules/bootstrap/scss/images"
@import "../../node_modules/bootstrap/scss/code"
@import "../../node_modules/bootstrap/scss/grid"
@import "../../node_modules/bootstrap/scss/tables"
@import "../../node_modules/bootstrap/scss/forms"
@import "../../node_modules/bootstrap/scss/buttons"
@import "../../node_modules/bootstrap/scss/transitions"
@import "../../node_modules/bootstrap/scss/dropdown"
@import "../../node_modules/bootstrap/scss/button-group"
@import "../../node_modules/bootstrap/scss/input-group"
@import "../../node_modules/bootstrap/scss/custom-forms"
@import "../../node_modules/bootstrap/scss/nav"
@import "../../node_modules/bootstrap/scss/navbar"
@import "../../node_modules/bootstrap/scss/card"
@import "../../node_modules/bootstrap/scss/breadcrumb"
@import "../../node_modules/bootstrap/scss/pagination"
@import "../../node_modules/bootstrap/scss/badge"
@import "../../node_modules/bootstrap/scss/jumbotron"
@import "../../node_modules/bootstrap/scss/alert"
@import "../../node_modules/bootstrap/scss/progress"
@import "../../node_modules/bootstrap/scss/media"
@import "../../node_modules/bootstrap/scss/list-group"
@import "../../node_modules/bootstrap/scss/close"
@import "../../node_modules/bootstrap/scss/modal"
@import "../../node_modules/bootstrap/scss/tooltip"
@import "../../node_modules/bootstrap/scss/popover"
@import "../../node_modules/bootstrap/scss/carousel"
@import "../../node_modules/bootstrap/scss/utilities"
@import "../../node_modules/bootstrap/scss/print"
// Pillar components.
@import "apps_base"
@import "components/base"
@import "components/jumbotron"
@import "components/alerts"
@import "components/navbar"
@import "components/dropdown"
@import "components/footer"
@import "components/shortcode"
@import "components/statusbar"
@import "components/search"
@import "components/flyout"
@import "components/forms"
@import "components/inputs"
@import "components/buttons"
@import "components/popover"
@import "components/tooltip"
@import "components/checkbox"
@import "components/overlay"
@import "components/card"
@import _notifications
@import _comments
@import _project
@import _project-sharing
@import _project-dashboard
@import _error
@import _search
@import plugins/_jstree
@import plugins/_js_select2

View File

@@ -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

View File

@@ -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="#",

View File

@@ -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 %}

View File

@@ -43,7 +43,6 @@ html(lang="en")
| {% block css %}
link(href="{{ url_for('static_pillar', filename='assets/css/font-pillar.css') }}", rel="stylesheet")
link(href="{{ url_for('static_pillar', filename='assets/css/base.css') }}", rel="stylesheet")
| {% if title == 'blog' %}
link(href="{{ url_for('static_pillar', filename='assets/css/blog.css') }}", rel="stylesheet")
| {% else %}

View File

@@ -1,10 +1,11 @@
| {% if current_user.is_authenticated %}
li.nav-notifications
a.navbar-item#notifications-toggle.px-0(
title="Notifications",
data-toggle="tooltip",
data-placement="bottom")
li.nav-notifications.nav-item
a.nav-link.px-2(
id="notifications-toggle",
title="Notifications",
data-toggle="tooltip",
data-placement="bottom")
i.pi-notifications-none.nav-notifications-icon
span#notifications-count
span

View File

@@ -12,25 +12,6 @@ li.dropdown
ul.dropdown-menu.dropdown-menu-right
| {% if not current_user.has_role('protected') %}
| {% block menu_list %}
li
a.navbar-item.px-2(
href="{{ url_for('projects.home_project') }}"
title="Home")
| #[i.pi-home] Home
li
a.navbar-item.px-2(
href="{{ url_for('projects.index') }}"
title="My Projects")
| #[i.pi-star] My Projects
| {% if current_user.has_organizations() %}
li
a.navbar-item.px-2(
href="{{ url_for('pillar.web.organizations.index') }}"
title="My Organizations")
| #[i.pi-users] My Organizations
| {% endif %}
li
a.navbar-item.px-2(
@@ -55,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

View File

@@ -6,7 +6,7 @@
// #}
mixin jumbotron(title, text, image, url)
if url
a.jumbotron.jumbotron-overlay.text-white(
a.jumbotron.text-white(
style='background-image: url(' + image + ');',
href=url)&attributes(attributes)
.container
@@ -18,8 +18,10 @@ mixin jumbotron(title, text, image, url)
if text
.lead
=text
if block
block
else
.jumbotron.jumbotron-overlay.text-white(style='background-image: url(' + image + ');')&attributes(attributes)
.jumbotron.text-white(style='background-image: url(' + image + ');')&attributes(attributes)
.container
.row
.col-md-9
@@ -29,14 +31,16 @@ mixin jumbotron(title, text, image, url)
if text
.lead
=text
if block
block
// {# Secondary navigation.
// e.g. Workshops, Courses. #}
mixin nav-secondary(title)
ul.nav.nav-secondary&attributes(attributes)
if title
li.font-weight-bold.px-2
=title
li.nav-item
span.nav-title.nav-link.font-weight-bold.pointer-events-none= title
if block
block
@@ -48,12 +52,10 @@ mixin nav-secondary-link()
a.nav-link&attributes(attributes)
block
mixin card-deck()
.card-deck.card-padless.card-deck-responsive()&attributes(attributes)
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

View File

@@ -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;
}
}

View File

@@ -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",

View File

@@ -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');
});

View File

@@ -1,8 +1,11 @@
| {% extends 'nodes/custom/blog/index.html' %}
| {% import 'nodes/custom/blog/_macros.html' as blogmacros %}
| {% block project_context %}
#blog_container
#blog_index-container.expand-image-links
| {{ blogmacros.render_archive(project, posts, posts_meta) }}
| {% endblock project_context%}
| {% block body %}
.container
.pt-5.pb-2
h2.text-uppercase.font-weight-bold.text-center
| {{ project.name }} Blog Archive
| {{ blogmacros.render_archive(project, posts, posts_meta) }}
| {% endblock body %}

View File

@@ -1,9 +0,0 @@
| {% extends 'nodes/custom/blog/index_main_project.html' %}
| {% import 'nodes/custom/blog/_macros.html' as blogmacros %}
| {% block body %}
.container
h3 Blog Archive
| {{ blogmacros.render_archive(project, posts, posts_meta) }}
| {% endblock body %}

View File

@@ -1,55 +1,44 @@
| {% extends 'projects/view.html' %}
| {% extends 'layout.html' %}
| {% import 'nodes/custom/blog/_macros.html' as blogmacros %}
| {% from '_macros/_navigation.html' import navigation_homepage, navigation_project %}
| {% set title = 'blog' %}
| {% block page_title %}Blog{% endblock%}
| {% block css %}
| {{ super() }}
link(href="{{ url_for('static_pillar', filename='assets/css/blog.css') }}", rel="stylesheet")
| {% endblock %}
| {% block navigation_tabs %}
| {% if project.url == 'blender-cloud' %}
| {{ navigation_homepage(title) }}
| {% else %}
| {{ navigation_project(project, navigation_links, title) }}
| {% endif %}
| {% endblock navigation_tabs %}
| {% block project_context %}
| {{ blogmacros.render_blog_index(project, posts, can_create_blog_posts, api, more_posts_available, posts_meta) }}
| {% endblock %}
| {% block project_tree %}
#project_tree.jstree.jstree-default.blog
ul.jstree-container-ul.jstree-children
li.jstree-node(data-node-type="page")
a.jstree-anchor(
href="{{ url_for('projects.view', project_url=project.url) }}")
| Browse Project
li.jstree-node(data-node-type="page")
a.jstree-anchor.jstree-clicked(
href="{{ url_for('main.project_blog', project_url=project.url) }}") Blog
| {% for post in posts %}
li.jstree-node
a.jstree-anchor.tree-item.post(
href="{{ node.url }}")
.tree-item-thumbnail
| {% if post.picture %}
img(src="{{ post.picture.thumbnail('s', api=api) }}")
| {% else %}
i.pi-document-text
| {% endif %}
span.tree-item-title {{ post.name }}
span.tree-item-info {{ post._created | pretty_date }}
| {% endfor %}
| {% block body %}
| {{ blogmacros.render_blog_index(node, project, posts, can_create_blog_posts, api, more_posts_available, posts_meta, pages=pages) }}
| {% endblock %}
| {% block footer_scripts %}
include ../_scripts
script.
/* UI Stuff */
var project_container = document.getElementById('project-container');
hopToTop(); // Display jump to top button
$(window).on("load resize",function(){
containerResizeY($(window).height());
/* Expand images when their link points to a jpg/png/gif */
/* TODO: De-duplicate code from view post */
var page_overlay = document.getElementById('page-overlay');
$('.item-content a img').on('click', function(e){
e.preventDefault();
if ($(window).width() > 480) {
project_container.style.height = (window.innerHeight - project_container.offsetTop) + "px";
var href = $(this).parent().attr('href');
var src = $(this).attr('src');
if (href.match("jpg$") || href.match("png$") || href.match("gif$")) {
$(page_overlay)
.addClass('active')
.html('<img src="' + src + '"/>');
} else {
window.location.href = href;
}
});

View File

@@ -1,40 +0,0 @@
| {% extends 'layout.html' %}
| {% import 'nodes/custom/blog/_macros.html' as blogmacros %}
| {% set title = 'blog' %}
| {% block page_title %}Blog{% endblock%}
| {% block css %}
| {{ super() }}
link(href="{{ url_for('static_cloud', filename='assets/css/project-landing.css') }}", rel="stylesheet")
| {% endblock css %}
| {% block body %}
| {{ blogmacros.render_blog_index(project, posts, can_create_blog_posts, api, more_posts_available, posts_meta, pages=pages) }}
| {% endblock %}
| {% block footer_scripts %}
include ../_scripts
script.
hopToTop(); // Display jump to top button
/* Expand images when their link points to a jpg/png/gif */
/* TODO: De-duplicate code from view post */
var page_overlay = document.getElementById('page-overlay');
$('.item-content a img').on('click', function(e){
e.preventDefault();
var href = $(this).parent().attr('href');
var src = $(this).attr('src');
if (href.match("jpg$") || href.match("png$") || href.match("gif$")) {
$(page_overlay)
.addClass('active')
.html('<img src="' + src + '"/>');
} else {
window.location.href = href;
}
});
| {% endblock %}

Some files were not shown because too many files have changed in this diff Show More