116 Commits

Author SHA1 Message Date
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
8ea0310956 Remove old videojs 2018-09-14 01:58:30 +02:00
c1958d2da7 Gulp: task to move vendor scripts
Only videojs at the moment.
2018-09-14 01:57:55 +02:00
030c5494a8 Cleanup: jQuery and Bootstrap are now part of tutti
Also remove font loading from Google, we use system fonts now.
2018-09-14 00:52:58 +02:00
462f31406a Package.json: videojs as new dependency
So it's easier to keep track of the version number.
2018-09-14 00:52:58 +02:00
1a1f67cf00 Cleanup: Remove markdown js scripts
Pillar has its own way to convert markdown (commonmark via backend) so it
does not longer need these files.
2018-09-14 00:52:58 +02:00
8d5bdf04aa Mixins no longer used 2018-09-13 18:10:39 +02:00
9a9d15ce47 Generate project_navigation_links
This function generates a list of selected links for important nodes such
as Pages and Blog. This list of links is used in the templates to provide
high level navigation of a Project.
2018-09-13 16:35:53 +02:00
c795015a3c Remove blog and page node types from jstree
They will be visible in project_navigation_links (see next commit).
2018-09-13 16:35:53 +02:00
afda0062f5 Navbar: Padding for items 2018-09-12 19:00:29 +02:00
a97c8ffc93 Search: Layout and styling 2018-09-12 19:00:16 +02:00
c5fa6b9535 Sass: set project_nav-width sizes 2018-09-12 18:59:12 +02:00
2be41a7145 Show author badges on assets and comments
Comments layout is still broken, marked as TODO(Pablo).
2018-09-12 15:58:29 +02:00
e8fb77c39b Badge sync: also support removal of all badges
Removal is stored as '' for the HTML. This way there is still the expiry
date, which means we won't repeatedly check for changes.
2018-09-12 15:29:45 +02:00
40933d51cf Show badges to users in their profile settings 2018-09-12 15:02:19 +02:00
9a9ca1bf8b Synchronise badges with Blender ID
Synchronisation is performed in the background by the Celery Beat, every
10 minutes. It has a time limit of 9 minutes to prevent multiple refresh
tasks from running at the same time.

Synchronisation is also possible with the `manage.py badges sync` CLI
command, which can sync either a single user or all users.
2018-09-12 15:02:19 +02:00
0983474e76 Store Blender ID OAuth scopes in MongoDB + request badge scope too
This also changes the way we treat Blender ID tokens. Before, the Blender ID
token was discarded and a random token was generated & stored. Now the
actual Blender ID token is stored.

The Facebook and Google OAuth code still uses the old approach of generating
a new token. Not sure what the added value is, though, because once the
Django session is gone there is nothing left to authenticate the user and
thus the random token is useless anyway.
2018-09-12 15:02:19 +02:00
6bcce87bb9 Sort celery task modules alphabetically 2018-09-12 15:02:19 +02:00
1401a6168f Always use urljoin to construct Blender ID URLs 2018-09-12 15:02:19 +02:00
85eab0c6cb No longer hash auth tokens + store the token scopes
This partially reverts commit c57aefd48b.
The code to check against hashed tokens remains, because existing tokens
should still work.

The unhashed tokens are necessary for fetching badges from Blender ID.
2018-09-12 15:02:19 +02:00
a753637e70 Thicker progress bar on cards 2018-09-11 19:45:42 +02:00
f87c7a25df Asset: style and cleanup listing
Font pillar aliases for asset icons
2018-09-11 19:37:22 +02:00
3ae16d7750 Tweaks to asset listing 2018-09-11 17:45:33 +02:00
c546dd2881 Video: new macro for showing video progress
Import video_progress_bar from '_macros/_asset_video_progress.html'
and pass it the video and current_user.
2018-09-11 16:11:05 +02:00
48df0583ab Layout and styling of asset groups 2018-09-11 15:16:37 +02:00
094d15116e Video progress: fixed issue in group node view_embed when never watched video 2018-09-11 15:01:11 +02:00
534d06ca8f Include video progress data in UserClass
See src/templates/nodes/custom/group/view_embed.pug for a crude example.
2018-09-11 14:06:45 +02:00
df078b395d Video progress: skip 'only reporting when paused' when forcing report
This ensures that the final pause at the end of a non-looping video is
also reported.
2018-09-11 14:06:45 +02:00
5df92ca4cf Use list-asset() mixin component for project index 2018-09-10 19:02:27 +02:00
ecace8c55b Navbar: style tweaks 2018-09-10 17:09:37 +02:00
bcacdfb7ea Project view: List of pages 2018-09-10 16:11:21 +02:00
d7fd90ded1 Videoplayer: Custom playback speed 2018-09-10 15:23:05 +02:00
b9268337c3 Videoplayer: Move loop functions outside of videojs() 2018-09-10 15:22:05 +02:00
9b62daec74 Search: Cleanup and minor fixes. 2018-09-10 11:56:31 +02:00
5cc5698477 Pillar Font: A couple new icons and update.
Also added comments on how to update this file in the future.
2018-09-10 11:55:59 +02:00
00ba98d279 Search: replace spinning loader with page-bar loader 2018-09-10 11:10:25 +02:00
e818c92d4e Assets: License style 2018-09-07 18:17:50 +02:00
612862c048 Use bootstrap classes where possible 2018-09-07 18:13:04 +02:00
6b3f025e16 Project Edit: Cleanup and styling 2018-09-07 17:21:02 +02:00
8a90cd00e9 Pug mixin components for jumbotron, secondary navigation and more. 2018-09-07 17:20:22 +02:00
17a69b973e Videoplayer: thicker progress bar 2018-09-07 14:55:42 +02:00
8380270128 Fixes on buttons/dropdown layout 2018-09-07 14:55:27 +02:00
35225a189d Replace #project-loading spinning icon with a .loader-bar 2018-09-07 14:55:04 +02:00
be98a95fc0 Assets: Fix download dropdown 2018-09-07 12:27:37 +02:00
95c1f913c6 Videoplayer small improvements
* Disable volume change on scroll
* Add L key shortcut to toggle loop
* Minor style fixes (missing font family)
2018-09-07 11:49:34 +02:00
9bcd6cec89 Cleanup and minor tweaks for apps with a sidebar
Like Attract or Flamenco
2018-09-06 18:18:22 +02:00
4532c1ea39 Updated package-lock.json 2018-09-06 16:09:25 +02:00
e19dd27099 API endpoint /api/nodes/tagged/<tag>
This endpoint returns nodes in public projects that have the given tag.
The returned JSON is cached for 5 minutes.
2018-09-06 15:42:50 +02:00
f54e56bad8 Allow predefined tags on nodes
Whenever a node has a 'tags' property of type 'list' it will be handled as
if it has {'allowed': app.config['NODE_TAGS']} in the node type definition.
2018-09-06 15:42:20 +02:00
eb851ce6e1 Added some type declarations
I added those for a certain use that ended up not being committed, but
those declarations are useful anyway.
2018-09-06 15:42:20 +02:00
586d9c0d3b Create MongoDB indices at Pillar startup, and not at first request
This makes things a little more predictable, and allowed me to actually
find & fix a bug in a unittest.
2018-09-06 15:42:20 +02:00
ac23c7b00b Bootstrap popovers are no longer used. 2018-09-06 14:24:09 +02:00
811edc5a2a Gulp: generate sourcemaps when not in production 2018-09-06 14:14:15 +02:00
cb95bf989a Updated package.lock by running ./gulp 2018-09-06 13:44:03 +02:00
e4fa32b8e4 Fixed bug in attachment code 2018-09-06 13:36:01 +02:00
08bf63c2ee Merge branch 'wip-redesign'
# Conflicts:
#	src/templates/projects/view.pug
2018-09-06 13:30:24 +02:00
bc16bb6e56 Send the request URL to Sentry
Also removed some dead code.
2018-09-05 14:54:30 +02:00
0fcafddbd1 Added unit test for creating comments
We had an issue creating comments, so I wrote a test for it. The test
succeeds on a new project, so the problem lies with the older projects.
In the end it was the comment node type that still had
`{'coerce': 'markdown'}`.
2018-09-05 14:54:08 +02:00
f29e01c78e Video player: remember volume in local storage 2018-09-04 12:16:24 +02:00
2698be3e12 Saving & restoring video watching progress
Video progress updates:

- Mark as 'done' when 90% or more is watched.
- Keep 'done' flag when re-watching.

The video progress is stored on three events, whichever comes first:

- Every 30 seconds of video.
- Every 10% of the video.
- Every pause/stop/navigation to another page.
- When we detect the video is looping.
2018-09-04 12:16:24 +02:00
3f8e0396cf VideoJS: don't use videojs.registerPlugin() to start Google Analytics
The `registerPlugin()` call should only be done once, and not for every
video shown.

This removes the warning about the 'analytics' plugin already being
registered, which you see when navigating from one video to another via
the JSTree.
2018-08-31 17:19:27 +02:00
05c488c484 Authentication: also accept user from session on API calls
When loading the user from the session, a CSRF check is performed.
2018-08-31 17:18:46 +02:00
40c19a3cb0 pillar.api.utils.utcnow() now truncates microseconds to milliseconds
MongoDB stores datetimes in millisecond precision, to keep datetimes the
same when roundtripping via MongoDB we now truncate the microseconds.
2018-08-31 11:26:32 +02:00
a67527d6af Use app_context() instead of test_request_context()
There is no request context needed here.
2018-08-30 18:28:17 +02:00
791906521f Added a test context manager to log in when doing Flask test client requests 2018-08-30 18:27:55 +02:00
2ad5b20880 Quick hack to get /p/{url}/jstree working again
Apparently Eve is now stricter in checking against MONGO_QUERY_BLACKLIST,
and blocks our use of $regex when getting child nodes. See
`jstree.py::jstree_get_children()`
2018-08-30 13:59:23 +02:00
f6fd9228e5 Upgrade Celery (fixes a problem with workers not starting) 2018-08-30 12:31:54 +02:00
e9f303f330 Re-pinned dependency versions 2018-08-30 12:04:57 +02:00
00a7406a1e Ignore .pytest_cache 2018-08-30 11:00:36 +02:00
82aa521b5f Merge branch 'master' into wip-flask-one 2018-08-30 10:59:00 +02:00
f7220924bc Replaced deprecated call to collection.count() 2018-08-30 10:33:30 +02:00
595bb48741 Silence warning of Flask-Caching about NULL cache during testing 2018-08-29 15:23:47 +02:00
1c430044b9 More urljoin() instead of string concatenation 2018-08-29 14:28:24 +02:00
73bc084417 Cerberus or Eve apparently changed validator._id to document_id 2018-08-29 14:18:24 +02:00
37ca803162 Flask wrapped Response replaced json() function with json property 2018-08-29 14:18:07 +02:00
939bb97f13 Revert 9389fef8ba 2018-08-29 14:17:38 +02:00
2c40665271 Use urljoin() to compose OAuth URLs instead of string concatenation
String concatenation is bound to mess up; in this case it was producing
double slashes instead of single ones when `BLENDER_ID_ENDPOINT` ends in
a slash. Since URLs generally end in a slash, this should be supported.
2018-08-29 14:17:17 +02:00
e8123b7839 Apparently the test client now uses `https://localhost.local/' as URL
Previously this was 'http://localhost/'
2018-08-29 11:27:00 +02:00
6d6a40b8c0 Empty lists don't seem to be stored in MongoDB any more
It looks like with the new Eve (or one of its dependencies) empty lists
aren't stored any more; rather than storing `{'org_roles': []}`, it skips
the `'org_roles'` key altogether. Not sure what caused this, as it was
mentioned in neither the Eve nor the PyMongo changelog.
2018-08-29 11:26:19 +02:00
efd345ec46 Upgrade attachments CLI cmd: added compatibility with new 'validator' key
We now support both the old coerce=markdown and the new validator=markdown.
Probably support for the old can be removed, but I'm keeping it around
just to be sure.
2018-08-29 11:24:44 +02:00
d655d2b749 Users schema: don't supply schema when allow_known=True
Apparently the new Cerberus doesn't like this, and will check against the
schema only and ignore `allow_unknown` when it's there.
2018-08-29 11:23:19 +02:00
a58e616769 Markdown validator: gracefully handle partial document validation
Validation of partial documents can happen when validating an update.
Missing data is fine then.
2018-08-29 11:22:39 +02:00
a8a7166e78 Use self.assertRaises as context manager 2018-08-28 17:45:58 +02:00
1649591d75 Create a copy in the validator's self.document
This ensures that further modifications (like setting '_etag' etc.) aren't
done in-place.
2018-08-28 17:45:44 +02:00
9389fef8ba Explicitly install pyasn1, solves certain build/test problems 2018-08-28 17:29:53 +02:00
6737aa1123 Markdown validator now also updates the doc with post_internal
The post_internal function does `document = validator.document`, replacing
the to-be-posted document by the copy that Cerberus made (and which we
cannot add keys to because it iterates over the keys and the dict size thus
isn't allowed to change).

I hope this doesn't break other validators who expect to be able to write
to `self.document`.
2018-08-28 17:29:29 +02:00
469f24d113 Fix for {validate: markdown} when used in Eve
Eve's Validator has not only a validate() function, but also
validate_update() and validate_replace(). Those set
self.persisted_document, so if that attribute exists we just use it.
2018-07-13 17:14:06 +02:00
8a0f582a80 Removed dependency on flask_pymongo 2018-07-13 17:08:06 +02:00
559e212c55 Removed debug prints + added TODO(fsiddi) 2018-07-13 17:04:23 +02:00
61278730c6 De-indent the code a bit 2018-07-13 17:02:47 +02:00
0fdcbc3947 Restored MarkDown conversion using 'validator': 'markdown' 2018-07-13 17:02:38 +02:00
8dc3296bd5 Schema change for IP range, use validator instead of type
Custom types became rather useless in Cerberus 1.0 since the type checker
is cripled (doesn't know field name, cannot return useful/detailed error
messages). Instead we use a validator now.
2018-07-13 15:03:35 +02:00
a699138fd6 Merge branch 'master' into wip-flask-one 2018-07-13 13:50:24 +02:00
7da741f354 Re-enabled PATCH handler for organisations 2018-07-13 13:36:59 +02:00
41369d134c Fix bloody Eve raising exceptions instead of returning status code 2018-07-13 12:45:58 +02:00
61ed083218 Don't change the global schema! 2018-07-13 12:33:22 +02:00
46777f7f8c Removed unnecessary ['shema'] 2018-07-13 12:06:48 +02:00
ef94c68177 Re-enabled the 'valid_properties': True in nodes_schema 2018-07-13 12:06:38 +02:00
aaf452e18b Fixed Cerberus canary unit test
Apparently it's no longer possible for Cerberus to validate its own schemas.
2018-07-13 12:02:40 +02:00
c607eaf23d Added magic custom validation rule schemas in docstrings 2018-07-13 12:02:18 +02:00
baa77a7de5 Merge branch 'master' into wip-flask-one 2018-07-13 11:43:57 +02:00
c83a1a21b8 Unpinned a bunch of package versions
This helps us get the latest versions and test with those, instead.
2018-07-13 11:01:22 +02:00
549cf0a3e8 WIP on libraries upgrade 2018-07-12 15:23:57 +02:00
132 changed files with 5629 additions and 9046 deletions

2
.gitignore vendored
View File

@@ -12,6 +12,7 @@ config_local.py
/build
/.cache
/.pytest_cache/
/*.egg-info/
profile.stats
/dump/
@@ -26,6 +27,7 @@ profile.stats
pillar/web/static/assets/css/*.css
pillar/web/static/assets/js/*.min.js
pillar/web/static/assets/js/vendor/video.min.js
pillar/web/static/storage/
pillar/web/static/uploads/
pillar/web/templates/

View File

@@ -16,7 +16,7 @@ var uglify = require('gulp-uglify-es').default;
var enabled = {
uglify: argv.production,
maps: argv.production,
maps: !argv.production,
failCheck: !argv.production,
prettyPug: !argv.production,
cachify: !argv.production,
@@ -32,6 +32,7 @@ var destination = {
var source = {
bootstrap: 'node_modules/bootstrap/',
jquery: 'node_modules/jquery/',
popper: 'node_modules/popper.js/'
}
@@ -78,10 +79,23 @@ gulp.task('scripts', function() {
});
/* Collection of scripts in src/scripts/tutti/ to merge into tutti.min.js */
/* Since it's always loaded, it's only for functions that we want site-wide */
/* Collection of scripts in src/scripts/tutti/ to merge into tutti.min.js
* Since it's always loaded, it's only for functions that we want site-wide.
* It also includes jQuery and Bootstrap (and its dependency popper), since
* the site doesn't work without it anyway.*/
gulp.task('scripts_concat_tutti', function() {
gulp.src('src/scripts/tutti/**/*.js')
toUglify = [
source.jquery + 'dist/jquery.min.js',
source.popper + 'dist/umd/popper.min.js',
source.bootstrap + 'js/dist/index.js',
source.bootstrap + 'js/dist/util.js',
source.bootstrap + 'js/dist/tooltip.js',
source.bootstrap + 'js/dist/dropdown.js',
'src/scripts/tutti/**/*.js'
];
gulp.src(toUglify)
.pipe(gulpif(enabled.failCheck, plumber()))
.pipe(gulpif(enabled.maps, sourcemaps.init()))
.pipe(concat("tutti.min.js"))
@@ -92,39 +106,17 @@ gulp.task('scripts_concat_tutti', function() {
.pipe(gulpif(argv.livereload, livereload()));
});
gulp.task('scripts_concat_markdown', function() {
gulp.src('src/scripts/markdown/**/*.js')
.pipe(gulpif(enabled.failCheck, plumber()))
.pipe(gulpif(enabled.maps, sourcemaps.init()))
.pipe(concat("markdown.min.js"))
.pipe(gulpif(enabled.uglify, uglify()))
.pipe(gulpif(enabled.maps, sourcemaps.write(".")))
.pipe(gulpif(enabled.chmod, chmod(644)))
.pipe(gulp.dest(destination.js))
.pipe(gulpif(argv.livereload, livereload()));
});
/* Simply move these vendor scripts from node_modules. */
gulp.task('scripts_move_vendor', function(done) {
// Combine all needed Bootstrap JavaScript into a single file.
gulp.task('scripts_concat_bootstrap', function() {
toUglify = [
source.popper + 'dist/umd/popper.min.js',
source.bootstrap + 'js/dist/index.js',
source.bootstrap + 'js/dist/util.js',
source.bootstrap + 'js/dist/tooltip.js',
source.bootstrap + 'js/dist/dropdown.js',
let toMove = [
'node_modules/video.js/dist/video.min.js',
];
gulp.src(toUglify)
.pipe(gulpif(enabled.failCheck, plumber()))
.pipe(gulpif(enabled.maps, sourcemaps.init()))
.pipe(concat("bootstrap.min.js"))
.pipe(gulpif(enabled.uglify, uglify()))
.pipe(gulpif(enabled.maps, sourcemaps.write(".")))
.pipe(gulpif(enabled.chmod, chmod(644)))
.pipe(gulp.dest(destination.js))
.pipe(gulpif(argv.livereload, livereload()));
gulp.src(toMove)
.pipe(gulp.dest(destination.js + '/vendor/'));
done();
});
@@ -139,9 +131,9 @@ gulp.task('watch',function() {
gulp.watch('src/templates/**/*.pug',['templates']);
gulp.watch('src/scripts/*.js',['scripts']);
gulp.watch('src/scripts/tutti/**/*.js',['scripts_concat_tutti']);
gulp.watch('src/scripts/markdown/**/*.js',['scripts_concat_markdown']);
});
// Erases all generated files in output directories.
gulp.task('cleanup', function() {
var paths = [];
@@ -164,6 +156,5 @@ gulp.task('default', tasks.concat([
'templates',
'scripts',
'scripts_concat_tutti',
'scripts_concat_markdown',
'scripts_concat_bootstrap',
'scripts_move_vendor',
]));

3597
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +1,32 @@
{
"name": "pillar",
"license": "GPL-2.0+",
"author": "Blender Institute",
"repository": {
"type": "git",
"url": "git://git.blender.org/pillar.git"
},
"devDependencies": {
"gulp": "^3.9.1",
"gulp-autoprefixer": "^6.0.0",
"gulp-cached": "^1.1.1",
"gulp-chmod": "^2.0.0",
"gulp-concat": "^2.6.1",
"gulp-if": "^2.0.2",
"gulp-git": "^2.8.0",
"gulp-livereload": "^4.0.0",
"gulp-plumber": "^1.2.0",
"gulp-pug": "^4.0.1",
"gulp-rename": "^1.4.0",
"gulp-sass": "^4.0.1",
"gulp-sourcemaps": "^2.6.4",
"gulp-uglify-es": "^1.0.4",
"minimist": "^1.2.0"
},
"dependencies": {
"bootstrap": "^4.1.3",
"jquery": "^3.3.1",
"popper.js": "^1.14.4"
}
"name": "pillar",
"license": "GPL-2.0+",
"author": "Blender Institute",
"repository": {
"type": "git",
"url": "git://git.blender.org/pillar.git"
},
"devDependencies": {
"gulp": "^3.9.1",
"gulp-autoprefixer": "^6.0.0",
"gulp-cached": "^1.1.1",
"gulp-chmod": "^2.0.0",
"gulp-concat": "^2.6.1",
"gulp-if": "^2.0.2",
"gulp-git": "^2.8.0",
"gulp-livereload": "^4.0.0",
"gulp-plumber": "^1.2.0",
"gulp-pug": "^4.0.1",
"gulp-rename": "^1.4.0",
"gulp-sass": "^4.0.1",
"gulp-sourcemaps": "^2.6.4",
"gulp-uglify-es": "^1.0.4",
"minimist": "^1.2.0"
},
"dependencies": {
"bootstrap": "^4.1.3",
"jquery": "^3.3.1",
"popper.js": "^1.14.4",
"video.js": "^7.2.2"
}
}

View File

@@ -140,8 +140,6 @@ class PillarServer(BlinkerCompatibleEve):
self.org_manager = pillar.api.organizations.OrgManager()
self.before_first_request(self.setup_db_indices)
# Make CSRF protection available to the application. By default it is
# disabled on all endpoints. More info at WTF_CSRF_CHECK_DEFAULT in config.py
self.csrf = CSRFProtect(self)
@@ -280,7 +278,7 @@ class PillarServer(BlinkerCompatibleEve):
self.encoding_service_client = Zencoder(self.config['ZENCODER_API_KEY'])
def _config_caching(self):
from flask_cache import Cache
from flask_caching import Cache
self.cache = Cache(self)
def set_languages(self, translations_folder: pathlib.Path):
@@ -479,10 +477,11 @@ class PillarServer(BlinkerCompatibleEve):
# Pillar-defined Celery task modules:
celery_task_modules = [
'pillar.celery.tasks',
'pillar.celery.search_index_tasks',
'pillar.celery.file_link_tasks',
'pillar.celery.badges',
'pillar.celery.email_tasks',
'pillar.celery.file_link_tasks',
'pillar.celery.search_index_tasks',
'pillar.celery.tasks',
]
# Allow Pillar extensions from defining their own Celery tasks.
@@ -704,6 +703,8 @@ class PillarServer(BlinkerCompatibleEve):
def finish_startup(self):
self.log.info('Using MongoDB database %r', self.config['MONGO_DBNAME'])
with self.app_context():
self.setup_db_indices()
self._config_celery()
api.setup_app(self)
@@ -760,6 +761,8 @@ class PillarServer(BlinkerCompatibleEve):
coll.create_index([('properties.status', pymongo.ASCENDING),
('node_type', pymongo.ASCENDING),
('_created', pymongo.DESCENDING)])
# Used for asset tags
coll.create_index([('properties.tags', pymongo.ASCENDING)])
coll = db['projects']
# This index is used for statistics, and for fetching public projects.

View File

@@ -6,6 +6,7 @@ with Blender ID.
import datetime
import logging
from urllib.parse import urljoin
import requests
from bson import tz_util
@@ -114,13 +115,14 @@ def validate_token(user_id, token, oauth_subclient_id):
# We only want to accept Blender Cloud tokens.
payload['client_id'] = current_app.config['OAUTH_CREDENTIALS']['blender-id']['id']
url = '{0}/u/validate_token'.format(current_app.config['BLENDER_ID_ENDPOINT'])
blender_id_endpoint = current_app.config['BLENDER_ID_ENDPOINT']
url = urljoin(blender_id_endpoint, 'u/validate_token')
log.debug('POSTing to %r', url)
# Retry a few times when POSTing to BlenderID fails.
# Source: http://stackoverflow.com/a/15431343/875379
s = requests.Session()
s.mount(current_app.config['BLENDER_ID_ENDPOINT'], HTTPAdapter(max_retries=5))
s.mount(blender_id_endpoint, HTTPAdapter(max_retries=5))
# POST to Blender ID, handling errors as negative verification results.
try:
@@ -218,7 +220,7 @@ def fetch_blenderid_user() -> dict:
my_log = log.getChild('fetch_blenderid_user')
bid_url = '%s/api/user' % current_app.config['BLENDER_ID_ENDPOINT']
bid_url = urljoin(current_app.config['BLENDER_ID_ENDPOINT'], 'api/user')
my_log.debug('Fetching user info from %s', bid_url)
credentials = current_app.config['OAUTH_CREDENTIALS']['blender-id']
@@ -263,7 +265,7 @@ def setup_app(app, url_prefix):
def switch_user_url(next_url: str) -> str:
from urllib.parse import quote
base_url = '%s/switch' % current_app.config['BLENDER_ID_ENDPOINT']
base_url = urljoin(current_app.config['BLENDER_ID_ENDPOINT'], 'switch')
if next_url:
return '%s?next=%s' % (base_url, quote(next_url))
return base_url

View File

@@ -1,8 +1,8 @@
import copy
from datetime import datetime
import logging
from bson import ObjectId, tz_util
from datetime import datetime
import cerberus.errors
from eve.io.mongo import Validator
from flask import current_app
@@ -12,6 +12,31 @@ log = logging.getLogger(__name__)
class ValidateCustomFields(Validator):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Will be reference to the actual document being validated, so that we can
# modify it during validation.
self.__real_document = None
def validate(self, document, *args, **kwargs):
# Keep a reference to the actual document, because Cerberus validates copies.
self.__real_document = document
result = super().validate(document, *args, **kwargs)
# Store the in-place modified document as self.document, so that Eve's post_internal
# can actually pick it up as the validated document. We need to make a copy so that
# further modifications (like setting '_etag' etc.) aren't done in-place.
self.document = copy.deepcopy(document)
return result
def _get_child_validator(self, *args, **kwargs):
child = super()._get_child_validator(*args, **kwargs)
# Pass along our reference to the actual document.
child.__real_document = self.__real_document
return child
# TODO: split this into a convert_property(property, schema) and call that from this function.
def convert_properties(self, properties, node_schema):
"""Converts datetime strings and ObjectId strings to actual Python objects."""
@@ -73,6 +98,11 @@ class ValidateCustomFields(Validator):
dict_property[key] = self.convert_properties(item_prop, item_schema)['item']
def _validate_valid_properties(self, valid_properties, field, value):
"""Fake property that triggers node dynamic property validation.
The rule's arguments are validated against this schema:
{'type': 'boolean'}
"""
from pillar.api.utils import project_get_node_type
projects_collection = current_app.data.driver.db['projects']
@@ -107,7 +137,8 @@ class ValidateCustomFields(Validator):
if val:
# This ensures the modifications made by v's coercion rules are
# visible to this validator's output.
self.current[field] = v.current
# TODO(fsiddi): this no longer works due to Cerberus internal changes.
# self.current[field] = v.current
return True
log.warning('Error validating properties for node %s: %s', self.document, v.errors)
@@ -118,6 +149,9 @@ class ValidateCustomFields(Validator):
Combine "required_after_creation=True" with "required=False" to allow
pre-insert hooks to set default values.
The rule's arguments are validated against this schema:
{'type': 'boolean'}
"""
if not required_after_creation:
@@ -125,14 +159,14 @@ class ValidateCustomFields(Validator):
# validator at all.
return
if self._id is None:
if self.document_id is None:
# This is a creation call, in which case this validator shouldn't run.
return
if not value:
self._error(field, "Value is required once the document was created")
def _validate_type_iprange(self, field_name: str, value: str):
def _validator_iprange(self, field_name: str, value: str):
"""Ensure the field contains a valid IP address.
Supports both IPv6 and IPv4 ranges. Requires the IPy module.
@@ -149,40 +183,36 @@ class ValidateCustomFields(Validator):
if ip.prefixlen() == 0:
self._error(field_name, 'Zero-length prefix is not allowed')
def _validate_type_binary(self, field_name: str, value: bytes):
"""Add support for binary type.
This type was actually introduced in Cerberus 1.0, so we can drop
support for this once Eve starts using that version (or newer).
def _validator_markdown(self, field, value):
"""Convert MarkDown.
"""
my_log = log.getChild('_validator_markdown')
if not isinstance(value, (bytes, bytearray)):
self._error(field_name, f'wrong value type {type(value)}, expected bytes or bytearray')
# Find this field inside the original document
my_subdoc = self._subdoc_in_real_document()
if my_subdoc is None:
# If self.update==True we are validating an update document, which
# may not contain all fields, so then a missing field is fine.
if not self.update:
self._error(field, f'validator_markdown: unable to find sub-document '
f'for path {self.document_path}')
return
def _validate_coerce(self, coerce, field: str, value):
"""Override Cerberus' _validate_coerce method for richer features.
This now supports named coercion functions (available in Cerberus 1.0+)
and passes the field name to coercion functions as well.
"""
if isinstance(coerce, str):
coerce = getattr(self, f'_normalize_coerce_{coerce}')
try:
return coerce(field, value)
except (TypeError, ValueError):
self._error(field, cerberus.errors.ERROR_COERCION_FAILED.format(field))
def _normalize_coerce_markdown(self, field: str, value):
"""Render Markdown from this field into {field}_html.
The field name MUST NOT end in `_html`. The Markdown is read from this
field and the rendered HTML is written to the field `{field}_html`.
"""
my_log.debug('validating field %r with value %r', field, value)
save_to = pillar.markdown.cache_field_name(field)
html = pillar.markdown.markdown(value)
field_name = pillar.markdown.cache_field_name(field)
self.current[field_name] = html
return value
my_log.debug('saving result to %r in doc with id %s', save_to, id(my_subdoc))
my_subdoc[save_to] = html
def _subdoc_in_real_document(self):
"""Return a reference to the current sub-document inside the real document.
This allows modification of the document being validated.
"""
my_subdoc = getattr(self, 'persisted_document') or self.__real_document
for item in self.document_path:
my_subdoc = my_subdoc[item]
return my_subdoc
if __name__ == '__main__':
@@ -190,12 +220,12 @@ if __name__ == '__main__':
v = ValidateCustomFields()
v.schema = {
'foo': {'type': 'string', 'coerce': 'markdown'},
'foo': {'type': 'string', 'validator': 'markdown'},
'foo_html': {'type': 'string'},
'nested': {
'type': 'dict',
'schema': {
'bar': {'type': 'string', 'coerce': 'markdown'},
'bar': {'type': 'string', 'validator': 'markdown'},
'bar_html': {'type': 'string'},
}
}

View File

@@ -121,12 +121,43 @@ users_schema = {
'service': {
'type': 'dict',
'allow_unknown': True,
},
# Node-specific information for this user.
'nodes': {
'type': 'dict',
'schema': {
'badger': {
'type': 'list',
'schema': {'type': 'string'}
}
}
# Per watched video info about where the user left off, both in time and in percent.
'view_progress': {
'type': 'dict',
# Keyed by Node ID of the video asset. MongoDB doesn't support using
# ObjectIds as key, so we cast them to string instead.
'keyschema': {'type': 'string'},
'valueschema': {
'type': 'dict',
'schema': {
'progress_in_sec': {'type': 'float', 'min': 0},
'progress_in_percent': {'type': 'integer', 'min': 0, 'max': 100},
# When the progress was last updated, so we can limit this history to
# the last-watched N videos if we want, or show stuff in chrono order.
'last_watched': {'type': 'datetime'},
# True means progress_in_percent = 100, for easy querying
'done': {'type': 'boolean', 'default': False},
},
},
},
},
},
'badges': {
'type': 'dict',
'schema': {
'html': {'type': 'string'}, # HTML fetched from Blender ID.
'expires': {'type': 'datetime'}, # When we should fetch it again.
},
},
# Properties defined by extensions. Extensions should use their name (see the
@@ -155,7 +186,7 @@ organizations_schema = {
'description': {
'type': 'string',
'maxlength': 256,
'coerce': 'markdown',
'validator': 'markdown',
},
'_description_html': {'type': 'string'},
'website': {
@@ -227,7 +258,7 @@ organizations_schema = {
'start': {'type': 'binary', 'required': True},
'end': {'type': 'binary', 'required': True},
'prefix': {'type': 'integer', 'required': True},
'human': {'type': 'iprange', 'required': True},
'human': {'type': 'string', 'required': True, 'validator': 'iprange'},
}
},
},
@@ -292,7 +323,7 @@ nodes_schema = {
},
'description': {
'type': 'string',
'coerce': 'markdown',
'validator': 'markdown',
},
'_description_html': {'type': 'string'},
'picture': _file_embedded_schema,
@@ -327,7 +358,7 @@ nodes_schema = {
'properties': {
'type': 'dict',
'valid_properties': True,
'required': True,
'required': True
},
'permissions': {
'type': 'dict',
@@ -345,11 +376,11 @@ tokens_schema = {
},
'token': {
'type': 'string',
'required': False,
'required': True,
},
'token_hashed': {
'type': 'string',
'required': True,
'required': False,
},
'expire_time': {
'type': 'datetime',
@@ -368,6 +399,13 @@ tokens_schema = {
'type': 'string',
},
},
# OAuth scopes granted to this token.
'oauth_scopes': {
'type': 'list',
'default': [],
'schema': {'type': 'string'},
}
}
files_schema = {
@@ -539,7 +577,7 @@ projects_schema = {
},
'description': {
'type': 'string',
'coerce': 'markdown',
'validator': 'markdown',
},
'_description_html': {'type': 'string'},
# Short summary for the project
@@ -833,4 +871,9 @@ UPSET_ON_PUT = False # do not create new document on PUT of non-existant URL.
X_DOMAINS = '*'
X_ALLOW_CREDENTIALS = True
X_HEADERS = 'Authorization'
XML = False
RENDERERS = ['eve.render.JSONRenderer']
# TODO(Sybren): this is a quick workaround to make /p/{url}/jstree work again.
# Apparently Eve is now stricter in checking against MONGO_QUERY_BLACKLIST, and
# blocks our use of $regex.
MONGO_QUERY_BLACKLIST = ['$where']

View File

@@ -94,17 +94,10 @@ def generate_and_store_token(user_id, days=15, prefix=b'') -> dict:
# Use 'xy' as altargs to prevent + and / characters from appearing.
# We never have to b64decode the string anyway.
token_bytes = prefix + base64.b64encode(random_bits, altchars=b'xy').strip(b'=')
token = token_bytes.decode('ascii')
token = prefix + base64.b64encode(random_bits, altchars=b'xy').strip(b'=')
token_expiry = utcnow() + datetime.timedelta(days=days)
token_data = store_token(user_id, token, token_expiry)
# Include the token in the returned document so that it can be stored client-side,
# in configuration, etc.
token_data['token'] = token
return token_data
return store_token(user_id, token.decode('ascii'), token_expiry)
def hash_password(password: str, salt: typing.Union[str, bytes]) -> str:

View File

@@ -12,7 +12,7 @@ ATTACHMENT_SLUG_REGEX = r'[a-zA-Z0-9_\-]+'
attachments_embedded_schema = {
'type': 'dict',
# TODO: will be renamed to 'keyschema' in Cerberus 1.0
'propertyschema': {
'keyschema': {
'type': 'string',
'regex': '^%s$' % ATTACHMENT_SLUG_REGEX,
},

View File

@@ -7,7 +7,7 @@ node_type_comment = {
'type': 'string',
'minlength': 5,
'required': True,
'coerce': 'markdown',
'validator': 'markdown',
},
'_content_html': {'type': 'string'},
'status': {

View File

@@ -3,7 +3,7 @@ node_type_group = {
'description': 'Folder node type',
'parent': ['group', 'project'],
'dyn_schema': {
# Used for sorting within the context of a group
'order': {
'type': 'integer'
},
@@ -20,7 +20,8 @@ node_type_group = {
'notes': {
'type': 'string',
'maxlength': 256,
},
}
},
'form_schema': {
'url': {'visible': False},

View File

@@ -9,7 +9,7 @@ node_type_post = {
'minlength': 5,
'maxlength': 90000,
'required': True,
'coerce': 'markdown',
'validator': 'markdown',
},
'_content_html': {'type': 'string'},
'status': {

View File

@@ -1,6 +1,7 @@
import base64
import functools
import logging
import typing
import urllib.parse
import pymongo.errors
@@ -8,6 +9,7 @@ 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
@@ -88,6 +90,67 @@ def share_node(node_id):
return jsonify(short_link_info(short_code), status=status)
@blueprint.route('/tagged/')
@blueprint.route('/tagged/<tag>')
def tagged(tag=''):
"""Return all tagged nodes of public projects as JSON."""
from pillar.auth import current_user
# We explicitly register the tagless endpoint to raise a 404, otherwise the PATCH
# handler on /api/nodes/<node_id> will return a 405 Method Not Allowed.
if not tag:
raise wz_exceptions.NotFound()
# Build the (cached) list of tagged nodes
agg_list = _tagged(tag)
# If the user is anonymous, no more information is needed and we return
if current_user.is_anonymous:
return jsonify(agg_list)
# If the user is authenticated, attach view_progress for video assets
view_progress = current_user.nodes['view_progress']
for node in agg_list:
node_id = str(node['_id'])
# View progress should be added only for nodes of type 'asset' and
# with content_type 'video', only if the video was already in the watched
# list for the current user.
if node_id in view_progress:
node['view_progress'] = view_progress[node_id]
return jsonify(agg_list)
def _tagged(tag: str):
"""Fetch all public nodes with the given tag.
This function is cached, see setup_app().
"""
nodes_coll = current_app.db('nodes')
agg = nodes_coll.aggregate([
{'$match': {'properties.tags': tag,
'_deleted': {'$ne': True}}},
# Only get nodes from public projects. This is done after matching the
# tagged nodes, because most likely nobody else will be able to tag
# nodes anyway.
{'$lookup': {
'from': 'projects',
'localField': 'project',
'foreignField': '_id',
'as': '_project',
}},
{'$match': {'_project.is_private': False}},
# Don't return the entire project for each node.
{'$project': {'_project': False}},
{'$sort': {'_created': -1}}
])
return list(agg)
def generate_and_store_short_code(node):
nodes_coll = current_app.data.driver.db['nodes']
node_id = node['_id']
@@ -400,7 +463,52 @@ def textures_sort_files(nodes):
texture_sort_files(node)
def parse_markdown(node, original=None):
import copy
projects_collection = current_app.data.driver.db['projects']
project = projects_collection.find_one({'_id': node['project']}, {'node_types': 1})
# Query node type directly using the key
node_type = next(nt for nt in project['node_types']
if nt['name'] == node['node_type'])
# Create a copy to not overwrite the actual schema.
schema = copy.deepcopy(current_app.config['DOMAIN']['nodes']['schema'])
schema['properties'] = node_type['dyn_schema']
def find_markdown_fields(schema, node):
"""Find and process all makrdown validated fields."""
for k, v in schema.items():
if not isinstance(v, dict):
continue
if v.get('validator') == 'markdown':
# If there is a match with the validator: markdown pair, assign the sibling
# property (following the naming convention _<property>_html)
# the processed value.
if k in node:
html = pillar.markdown.markdown(node[k])
field_name = pillar.markdown.cache_field_name(k)
node[field_name] = html
if isinstance(node, dict) and k in node:
find_markdown_fields(v, node[k])
find_markdown_fields(schema, node)
return 'ok'
def parse_markdowns(items):
for item in items:
parse_markdown(item)
def setup_app(app, url_prefix):
global _tagged
cached = app.cache.memoize(timeout=300)
_tagged = cached(_tagged)
from . import patch
patch.setup_app(app, url_prefix=url_prefix)
@@ -408,12 +516,14 @@ def setup_app(app, url_prefix):
app.on_fetched_resource_nodes += 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_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

View File

@@ -1,7 +1,7 @@
"""Code for moving around nodes."""
import attr
import flask_pymongo.wrappers
import pymongo.database
from bson import ObjectId
from pillar import attrs_extra
@@ -10,7 +10,7 @@ import pillar.api.file_storage.moving
@attr.s
class NodeMover(object):
db = attr.ib(validator=attr.validators.instance_of(flask_pymongo.wrappers.Database))
db = attr.ib(validator=attr.validators.instance_of(pymongo.database.Database))
skip_gcs = attr.ib(default=False, validator=attr.validators.instance_of(bool))
_log = attrs_extra.log('%s.NodeMover' % __name__)

View File

@@ -71,14 +71,19 @@ def before_delete_project(document):
def after_delete_project(project: dict):
"""Perform delete on the project's files too."""
from werkzeug.exceptions import NotFound
from eve.methods.delete import delete
pid = project['_id']
log.info('Project %s was deleted, also deleting its files.', pid)
r, _, _, status = delete('files', {'project': pid})
try:
r, _, _, status = delete('files', {'project': pid})
except NotFound:
# There were no files, and that's fine.
return
if status != 204:
# Will never happen because bloody Eve always returns 204 or raises an exception.
log.warning('Unable to delete files of project %s: %s', pid, r)

View File

@@ -142,7 +142,7 @@ def after_fetching_user(user):
return
# Remove all fields except public ones.
public_fields = {'full_name', 'username', 'email', 'extension_props_public'}
public_fields = {'full_name', 'username', 'email', 'extension_props_public', 'badges'}
for field in list(user.keys()):
if field not in public_fields:
del user[field]

View File

@@ -1,9 +1,11 @@
import logging
from eve.methods.get import get
from flask import Blueprint
from flask import Blueprint, request
import werkzeug.exceptions as wz_exceptions
from pillar.api.utils import jsonify
from pillar import current_app
from pillar.api import utils
from pillar.api.utils.authorization import require_login
from pillar.auth import current_user
@@ -15,7 +17,128 @@ blueprint_api = Blueprint('users_api', __name__)
@require_login()
def my_info():
eve_resp, _, _, status, _ = get('users', {'_id': current_user.user_id})
resp = jsonify(eve_resp['_items'][0], status=status)
resp = utils.jsonify(eve_resp['_items'][0], status=status)
return resp
@blueprint_api.route('/video/<video_id>/progress')
@require_login()
def get_video_progress(video_id: str):
"""Return video progress information.
Either a `204 No Content` is returned (no information stored),
or a `200 Ok` with JSON from Eve's 'users' schema, from the key
video.view_progress.<video_id>.
"""
# Validation of the video ID; raises a BadRequest when it's not an ObjectID.
# This isn't strictly necessary, but it makes this function behave symmetrical
# to the set_video_progress() function.
utils.str2id(video_id)
users_coll = current_app.db('users')
user_doc = users_coll.find_one(current_user.user_id, projection={'nodes.view_progress': True})
try:
progress = user_doc['nodes']['view_progress'][video_id]
except KeyError:
return '', 204
if not progress:
return '', 204
return utils.jsonify(progress)
@blueprint_api.route('/video/<video_id>/progress', methods=['POST'])
@require_login()
def set_video_progress(video_id: str):
"""Save progress information about a certain video.
Expected parameters:
- progress_in_sec: float number of seconds
- progress_in_perc: integer percentage of video watched (interval [0-100])
"""
my_log = log.getChild('set_video_progress')
my_log.debug('Setting video progress for user %r video %r', current_user.user_id, video_id)
# Constructing this response requires an active app, and thus can't be done on module load.
no_video_response = utils.jsonify({'_message': 'No such video'}, status=404)
try:
progress_in_sec = float(request.form['progress_in_sec'])
progress_in_perc = int(request.form['progress_in_perc'])
except KeyError as ex:
my_log.debug('Missing POST field in request: %s', ex)
raise wz_exceptions.BadRequest(f'missing a form field')
except ValueError as ex:
my_log.debug('Invalid value for POST field in request: %s', ex)
raise wz_exceptions.BadRequest(f'Invalid value for field: {ex}')
users_coll = current_app.db('users')
nodes_coll = current_app.db('nodes')
# First check whether this is actually an existing video
video_oid = utils.str2id(video_id)
video_doc = nodes_coll.find_one(video_oid, projection={
'node_type': True,
'properties.content_type': True,
'properties.file': True,
})
if not video_doc:
my_log.debug('Node %r not found, unable to set progress for user %r',
video_oid, current_user.user_id)
return no_video_response
try:
is_video = (video_doc['node_type'] == 'asset'
and video_doc['properties']['content_type'] == 'video')
except KeyError:
is_video = False
if not is_video:
my_log.info('Node %r is not a video, unable to set progress for user %r',
video_oid, current_user.user_id)
# There is no video found at this URL, so act as if it doesn't even exist.
return no_video_response
# Compute the progress
percent = min(100, max(0, progress_in_perc))
progress = {
'progress_in_sec': progress_in_sec,
'progress_in_percent': percent,
'last_watched': utils.utcnow(),
}
# After watching a certain percentage of the video, we consider it 'done'
#
# Total Credit start Total Credit Percent
# HH:MM:SS HH:MM:SS sec sec of duration
# Sintel 00:14:48 00:12:24 888 744 83.78%
# Tears of Steel 00:12:14 00:09:49 734 589 80.25%
# Cosmos Laundro 00:12:10 00:10:05 730 605 82.88%
# Agent 327 00:03:51 00:03:26 231 206 89.18%
# Caminandes 3 00:02:30 00:02:18 150 138 92.00%
# Glass Half 00:03:13 00:02:52 193 172 89.12%
# Big Buck Bunny 00:09:56 00:08:11 596 491 82.38%
# Elephants Drea 00:10:54 00:09:25 654 565 86.39%
#
# Median 85.09%
# Average 85.75%
#
# For training videos marking at done at 85% of the video may be a bit
# early, since those probably won't have (long) credits. This is why we
# stick to 90% here.
if percent >= 90:
progress['done'] = True
# Setting each property individually prevents us from overwriting any
# existing {done: true} fields.
updates = {f'nodes.view_progress.{video_id}.{k}': v
for k, v in progress.items()}
result = users_coll.update_one({'_id': current_user.user_id},
{'$set': updates})
if result.matched_count == 0:
my_log.error('Current user %r could not be updated', current_user.user_id)
raise wz_exceptions.InternalServerError('Unable to find logged-in user')
return '', 204

View File

@@ -245,4 +245,10 @@ def random_etag() -> str:
def utcnow() -> datetime.datetime:
return datetime.datetime.now(tz=bson.tz_util.utc)
"""Construct timezone-aware 'now' in UTC with millisecond precision."""
now = datetime.datetime.now(tz=bson.tz_util.utc)
# MongoDB stores in millisecond precision, so truncate the microseconds.
# This way the returned datetime can be round-tripped via MongoDB and stay the same.
trunc_now = now.replace(microsecond=now.microsecond - (now.microsecond % 1000))
return trunc_now

View File

@@ -13,7 +13,7 @@ import logging
import typing
import bson
from flask import g, current_app
from flask import g, current_app, session
from flask import request
from werkzeug import exceptions as wz_exceptions
@@ -103,7 +103,7 @@ def find_user_in_db(user_info: dict, provider='blender-id') -> dict:
return db_user
def validate_token(*, force=False):
def validate_token(*, force=False) -> bool:
"""Validate the token provided in the request and populate the current_user
flask.g object, so that permissions and access to a resource can be defined
from it.
@@ -115,7 +115,7 @@ def validate_token(*, force=False):
:returns: True iff the user is logged in with a valid Blender ID token.
"""
from pillar.auth import AnonymousUser
import pillar.auth
# Trust a pre-existing g.current_user
if not force:
@@ -133,16 +133,22 @@ def validate_token(*, force=False):
oauth_subclient = ''
else:
# Check the session, the user might be logged in through Flask-Login.
from pillar import auth
token = auth.get_blender_id_oauth_token()
# The user has a logged-in session; trust only if this request passes a CSRF check.
# FIXME(Sybren): we should stop saving the token as 'user_id' in the sesion.
token = session.get('user_id')
if token:
log.debug('skipping token check because current user already has a session')
current_app.csrf.protect()
else:
token = pillar.auth.get_blender_id_oauth_token()
oauth_subclient = None
if not token:
# If no authorization headers are provided, we are getting a request
# from a non logged in user. Proceed accordingly.
log.debug('No authentication headers, so not logged in.')
g.current_user = AnonymousUser()
g.current_user = pillar.auth.AnonymousUser()
return False
return validate_this_token(token, oauth_subclient) is not None
@@ -194,7 +200,7 @@ def remove_token(token: str):
tokens_coll = current_app.db('tokens')
token_hashed = hash_auth_token(token)
# TODO: remove matching on unhashed tokens once all tokens have been hashed.
# TODO: remove matching on hashed tokens once all hashed tokens have expired.
lookup = {'$or': [{'token': token}, {'token_hashed': token_hashed}]}
del_res = tokens_coll.delete_many(lookup)
log.debug('Removed token %r, matched %d documents', token, del_res.deleted_count)
@@ -206,7 +212,7 @@ def find_token(token, is_subclient_token=False, **extra_filters):
tokens_coll = current_app.db('tokens')
token_hashed = hash_auth_token(token)
# TODO: remove matching on unhashed tokens once all tokens have been hashed.
# TODO: remove matching on hashed tokens once all hashed tokens have expired.
lookup = {'$or': [{'token': token}, {'token_hashed': token_hashed}],
'is_subclient_token': True if is_subclient_token else {'$in': [False, None]},
'expire_time': {"$gt": utcnow()}}
@@ -229,8 +235,14 @@ def hash_auth_token(token: str) -> str:
return base64.b64encode(digest).decode('ascii')
def store_token(user_id, token: str, token_expiry, oauth_subclient_id=False,
org_roles: typing.Set[str] = frozenset()):
def store_token(user_id,
token: str,
token_expiry,
oauth_subclient_id=False,
*,
org_roles: typing.Set[str] = frozenset(),
oauth_scopes: typing.Optional[typing.List[str]] = None,
):
"""Stores an authentication token.
:returns: the token document from MongoDB
@@ -240,13 +252,15 @@ def store_token(user_id, token: str, token_expiry, oauth_subclient_id=False,
token_data = {
'user': user_id,
'token_hashed': hash_auth_token(token),
'token': token,
'expire_time': token_expiry,
}
if oauth_subclient_id:
token_data['is_subclient_token'] = True
if org_roles:
token_data['org_roles'] = sorted(org_roles)
if oauth_scopes:
token_data['oauth_scopes'] = oauth_scopes
r, _, _, status = current_app.post_internal('tokens', token_data)

View File

@@ -1,5 +1,6 @@
import logging
import functools
import typing
from bson import ObjectId
from flask import g
@@ -12,8 +13,9 @@ CHECK_PERMISSIONS_IMPLEMENTED_FOR = {'projects', 'nodes', 'flamenco_jobs'}
log = logging.getLogger(__name__)
def check_permissions(collection_name, resource, method, append_allowed_methods=False,
check_node_type=None):
def check_permissions(collection_name: str, resource: dict, method: str,
append_allowed_methods=False,
check_node_type: typing.Optional[str] = None):
"""Check user permissions to access a node. We look up node permissions from
world to groups to users and match them with the computed user permissions.
If there is not match, we raise 403.
@@ -93,8 +95,9 @@ def compute_allowed_methods(collection_name, resource, check_node_type=None):
return allowed_methods
def has_permissions(collection_name, resource, method, append_allowed_methods=False,
check_node_type=None):
def has_permissions(collection_name: str, resource: dict, method: str,
append_allowed_methods=False,
check_node_type: typing.Optional[str] = None):
"""Check user permissions to access a node. We look up node permissions from
world to groups to users and match them with the computed user permissions.

View File

@@ -38,6 +38,8 @@ class UserClass(flask_login.UserMixin):
self.groups: typing.List[str] = [] # NOTE: these are stringified object IDs.
self.group_ids: typing.List[bson.ObjectId] = []
self.capabilities: typing.Set[str] = set()
self.nodes: dict = {} # see the 'nodes' key in eve_settings.py::user_schema.
self.badges_html: str = ''
# Lazily evaluated
self._has_organizations: typing.Optional[bool] = None
@@ -56,6 +58,12 @@ class UserClass(flask_login.UserMixin):
user.email = db_user.get('email') or ''
user.username = db_user.get('username') or ''
user.full_name = db_user.get('full_name') or ''
user.badges_html = db_user.get('badges', {}).get('html') or ''
# Be a little more specific than just db_user['nodes']
user.nodes = {
'view_progress': db_user.get('nodes', {}).get('view_progress', {}),
}
# Derived properties
user.objectid = str(user.user_id or '')
@@ -210,6 +218,11 @@ def login_user(oauth_token: str, *, load_from_db=False):
user = _load_user(oauth_token)
else:
user = UserClass(oauth_token)
login_user_object(user)
def login_user_object(user: UserClass):
"""Log in the given user."""
flask_login.login_user(user, remember=True)
g.current_user = user
user_authenticated.send(None)

View File

@@ -1,8 +1,9 @@
import abc
import attr
import json
import logging
import typing
import attr
from rauth import OAuth2Service
from flask import current_app, url_for, request, redirect, session, Response
@@ -15,6 +16,8 @@ class OAuthUserResponse:
id = attr.ib(validator=attr.validators.instance_of(str))
email = attr.ib(validator=attr.validators.instance_of(str))
access_token = attr.ib(validator=attr.validators.instance_of(str))
scopes: typing.List[str] = attr.ib(validator=attr.validators.instance_of(list))
class OAuthError(Exception):
@@ -127,8 +130,10 @@ class OAuthSignIn(metaclass=abc.ABCMeta):
class BlenderIdSignIn(OAuthSignIn):
provider_name = 'blender-id'
scopes = ['email', 'badge']
def __init__(self):
from urllib.parse import urljoin
super().__init__()
base_url = current_app.config['BLENDER_ID_ENDPOINT']
@@ -137,14 +142,14 @@ class BlenderIdSignIn(OAuthSignIn):
name='blender-id',
client_id=self.consumer_id,
client_secret=self.consumer_secret,
authorize_url='%s/oauth/authorize' % base_url,
access_token_url='%s/oauth/token' % base_url,
base_url='%s/api/' % base_url
authorize_url=urljoin(base_url, 'oauth/authorize'),
access_token_url=urljoin(base_url, 'oauth/token'),
base_url=urljoin(base_url, 'api/'),
)
def authorize(self):
return redirect(self.service.get_authorize_url(
scope='email',
scope=' '.join(self.scopes),
response_type='code',
redirect_uri=self.get_callback_url())
)
@@ -158,7 +163,11 @@ class BlenderIdSignIn(OAuthSignIn):
session['blender_id_oauth_token'] = access_token
me = oauth_session.get('user').json()
return OAuthUserResponse(str(me['id']), me['email'])
# Blender ID doesn't tell us which scopes were granted by the user, so
# for now assume we got all the scopes we requested.
# (see https://github.com/jazzband/django-oauth-toolkit/issues/644)
return OAuthUserResponse(str(me['id']), me['email'], access_token, self.scopes)
class FacebookSignIn(OAuthSignIn):
@@ -188,7 +197,7 @@ class FacebookSignIn(OAuthSignIn):
me = oauth_session.get('me?fields=id,email').json()
# TODO handle case when user chooses not to disclose en email
# see https://developers.facebook.com/docs/graph-api/reference/user/
return OAuthUserResponse(me['id'], me.get('email'))
return OAuthUserResponse(me['id'], me.get('email'), '', [])
class GoogleSignIn(OAuthSignIn):
@@ -216,4 +225,4 @@ class GoogleSignIn(OAuthSignIn):
oauth_session = self.make_oauth_session()
me = oauth_session.get('userinfo').json()
return OAuthUserResponse(str(me['id']), me['email'])
return OAuthUserResponse(str(me['id']), me['email'], '', [])

183
pillar/badge_sync.py Normal file
View File

@@ -0,0 +1,183 @@
import collections
import datetime
import logging
import typing
from urllib.parse import urljoin
import bson
import requests
from pillar import current_app
from pillar.api.utils import utcnow
SyncUser = collections.namedtuple('SyncUser', 'user_id token bid_user_id')
BadgeHTML = collections.namedtuple('BadgeHTML', 'html expires')
log = logging.getLogger(__name__)
class StopRefreshing(Exception):
"""Indicates that Blender ID is having problems.
Further badge refreshes should be put on hold to avoid bludgeoning
a suffering Blender ID.
"""
def find_users_to_sync() -> typing.Iterable[SyncUser]:
"""Return user information of syncable users with badges."""
now = utcnow()
tokens_coll = current_app.db('tokens')
cursor = tokens_coll.aggregate([
# Find all users who have a 'badge' scope in their OAuth token.
{'$match': {
'token': {'$exists': True},
'oauth_scopes': 'badge',
'expire_time': {'$gt': now},
}},
{'$lookup': {
'from': 'users',
'localField': 'user',
'foreignField': '_id',
'as': 'user'
}},
# Prevent 'user' from being an array.
{'$unwind': {'path': '$user'}},
# Get the Blender ID user ID only.
{'$unwind': {'path': '$user.auth'}},
{'$match': {'user.auth.provider': 'blender-id'}},
# Only select those users whose badge doesn't exist or has expired.
{'$match': {
'user.badges.expires': {'$not': {'$gt': now}}
}},
# Make sure that the badges that expire last are also refreshed last.
{'$sort': {'user.badges.expires': 1}},
# Reduce the document to the info we're after.
{'$project': {
'token': True,
'user._id': True,
'user.auth.user_id': True,
'user.badges.expires': True,
}},
])
log.debug('Aggregating tokens and users')
for user_info in cursor:
log.debug('User %s has badges %s',
user_info['user']['_id'], user_info['user'].get('badges'))
yield SyncUser(
user_id=user_info['user']['_id'],
token=user_info['token'],
bid_user_id=user_info['user']['auth']['user_id'])
def fetch_badge_html(session: requests.Session, user: SyncUser, size: str) \
-> str:
"""Fetch a Blender ID badge for this user.
:param session:
:param user:
:param size: Size indication for the badge images, see the Blender ID
documentation/code. As of this writing valid sizes are {'s', 'm', 'l'}.
"""
my_log = log.getChild('fetch_badge_html')
blender_id_endpoint = current_app.config['BLENDER_ID_ENDPOINT']
url = urljoin(blender_id_endpoint, f'api/badges/{user.bid_user_id}/html/{size}')
my_log.debug('Fetching badge HTML at %s for user %s', url, user.user_id)
try:
resp = session.get(url, headers={'Authorization': f'Bearer {user.token}'})
except requests.ConnectionError as ex:
my_log.warning('Unable to connect to Blender ID at %s: %s', url, ex)
raise StopRefreshing()
if resp.status_code == 204:
my_log.debug('No badges for user %s', user.user_id)
return ''
if resp.status_code == 403:
my_log.warning('Tried fetching %s for user %s but received a 403: %s',
url, user.user_id, resp.text)
return ''
if resp.status_code == 400:
my_log.warning('Blender ID did not accept our GET request at %s for user %s: %s',
url, user.user_id, resp.text)
return ''
if resp.status_code == 500:
my_log.warning('Blender ID returned an internal server error on %s for user %s, '
'aborting all badge refreshes: %s', url, user.user_id, resp.text)
raise StopRefreshing()
if resp.status_code == 404:
my_log.warning('Blender ID has no user %s for our user %s', user.bid_user_id, user.user_id)
return ''
resp.raise_for_status()
return resp.text
def refresh_all_badges(only_user_id: typing.Optional[bson.ObjectId] = None, *,
dry_run=False,
timelimit: datetime.timedelta):
"""Re-fetch all badges for all users, except when already refreshed recently.
:param only_user_id: Only refresh this user. This is expected to be used
sparingly during manual maintenance / debugging sessions only. It does
fetch all users to refresh, and in Python code skips all except the
given one.
:param dry_run: if True the changes are described in the log, but not performed.
:param timelimit: Refreshing will stop after this time. This allows for cron(-like)
jobs to run without overlapping, even when the number fo badges to refresh
becomes larger than possible within the period of the cron job.
"""
from requests.adapters import HTTPAdapter
my_log = log.getChild('fetch_badge_html')
# Test the config before we start looping over the world.
badge_expiry = badge_expiry_config()
if not badge_expiry or not isinstance(badge_expiry, datetime.timedelta):
raise ValueError('BLENDER_ID_BADGE_EXPIRY not configured properly, should be a timedelta')
session = requests.Session()
session.mount('https://', HTTPAdapter(max_retries=5))
users_coll = current_app.db('users')
deadline = utcnow() + timelimit
num_updates = 0
for user_info in find_users_to_sync():
if utcnow() > deadline:
my_log.info('Stopping badge refresh because the timelimit %s (H:MM:SS) was hit.',
timelimit)
break
if only_user_id and user_info.user_id != only_user_id:
my_log.debug('Skipping user %s', user_info.user_id)
continue
try:
badge_html = fetch_badge_html(session, user_info, 's')
except StopRefreshing:
my_log.error('Blender ID has internal problems, stopping badge refreshing at user %s',
user_info)
break
update = {'badges': {
'html': badge_html,
'expires': utcnow() + badge_expiry,
}}
num_updates += 1
my_log.info('Updating badges HTML for Blender ID %s, user %s',
user_info.bid_user_id, user_info.user_id)
if not dry_run:
result = users_coll.update_one({'_id': user_info.user_id},
{'$set': update})
if result.matched_count != 1:
my_log.warning('Unable to update badges for user %s', user_info.user_id)
my_log.info('Updated badges of %d users%s', num_updates, ' (dry-run)' if dry_run else '')
def badge_expiry_config() -> datetime.timedelta:
return current_app.config.get('BLENDER_ID_BADGE_EXPIRY')

20
pillar/celery/badges.py Normal file
View File

@@ -0,0 +1,20 @@
"""Badge HTML synchronisation.
Note that this module can only be imported when an application context is
active. Best to late-import this in the functions where it's needed.
"""
import datetime
import logging
from pillar import current_app, badge_sync
log = logging.getLogger(__name__)
@current_app.celery.task(ignore_result=True)
def sync_badges_for_users(timelimit_seconds: int):
"""Synchronises Blender ID badges for the most-urgent users."""
timelimit = datetime.timedelta(seconds=timelimit_seconds)
log.info('Refreshing badges, timelimit is %s (H:MM:SS)', timelimit)
badge_sync.refresh_all_badges(timelimit=timelimit)

View File

@@ -13,6 +13,7 @@ from pillar.cli.maintenance import manager_maintenance
from pillar.cli.operations import manager_operations
from pillar.cli.setup import manager_setup
from pillar.cli.elastic import manager_elastic
from . import badges
from pillar.cli import translations
@@ -24,3 +25,4 @@ manager.add_command("maintenance", manager_maintenance)
manager.add_command("setup", manager_setup)
manager.add_command("operations", manager_operations)
manager.add_command("elastic", manager_elastic)
manager.add_command("badges", badges.manager)

39
pillar/cli/badges.py Normal file
View File

@@ -0,0 +1,39 @@
import datetime
import logging
from flask_script import Manager
from pillar import current_app, badge_sync
from pillar.api.utils import utcnow
log = logging.getLogger(__name__)
manager = Manager(current_app, usage="Badge operations")
@manager.option('-u', '--user', dest='email', default='', help='Email address of the user to sync')
@manager.option('-a', '--all', dest='sync_all', action='store_true', default=False,
help='Sync all users')
@manager.option('--go', action='store_true', default=False,
help='Actually perform the sync; otherwise it is a dry-run.')
def sync(email: str = '', sync_all: bool=False, go: bool=False):
if bool(email) == bool(sync_all):
raise ValueError('Use either --user or --all.')
if email:
users_coll = current_app.db('users')
db_user = users_coll.find_one({'email': email}, projection={'_id': True})
if not db_user:
raise ValueError(f'No user with email {email!r} found')
specific_user = db_user['_id']
else:
specific_user = None
if not go:
log.info('Performing dry-run, not going to change the user database.')
start_time = utcnow()
badge_sync.refresh_all_badges(specific_user, dry_run=not go,
timelimit=datetime.timedelta(hours=1))
end_time = utcnow()
log.info('%s took %s (H:MM:SS)',
'Updating user badges' if go else 'Dry-run',
end_time - start_time)

View File

@@ -684,8 +684,8 @@ def upgrade_attachment_schema(proj_url=None, all_projects=False, go=False):
log_proj()
log.info('Removed %d empty attachment dicts', res.modified_count)
else:
to_remove = nodes_coll.count({'properties.attachments': {},
'project': project['_id']})
to_remove = nodes_coll.count_documents({'properties.attachments': {},
'project': project['_id']})
if to_remove:
log_proj()
log.info('Would remove %d empty attachment dicts', to_remove)
@@ -767,7 +767,9 @@ def iter_markdown(proj_node_types: dict, some_node: dict, callback: typing.Calla
continue
to_visit.append((subdoc, definition['schema']))
continue
if definition.get('coerce') != 'markdown':
coerce = definition.get('coerce') # Eve < 0.8
validator = definition.get('validator') # Eve >= 0.8
if coerce != 'markdown' and validator != 'markdown':
continue
my_log.debug('I have to change %r of %s', key, doc)

View File

@@ -1,6 +1,8 @@
from collections import defaultdict
import datetime
import os.path
from os import getenv
from collections import defaultdict
import requests.certs
# Certificate file for communication with other systems.
@@ -29,10 +31,11 @@ DEBUG = False
SECRET_KEY = ''
# Authentication token hashing key. If empty falls back to UTF8-encoded SECRET_KEY with a warning.
# Not used to hash new tokens, but it is used to check pre-existing hashed tokens.
AUTH_TOKEN_HMAC_KEY = b''
# Authentication settings
BLENDER_ID_ENDPOINT = 'http://id.local:8000'
BLENDER_ID_ENDPOINT = 'http://id.local:8000/'
CDN_USE_URL_SIGNING = True
CDN_SERVICE_DOMAIN_PROTOCOL = 'https'
@@ -203,8 +206,18 @@ CELERY_BEAT_SCHEDULE = {
'schedule': 600, # every N seconds
'args': ('gcs', 100)
},
'refresh-blenderid-badges': {
'task': 'pillar.celery.badges.sync_badges_for_users',
'schedule': 600, # every N seconds
'args': (540, ), # time limit in seconds, keep shorter than 'schedule'
}
}
# Badges will be re-fetched every timedelta.
# TODO(Sybren): A proper value should be determined after we actually have users with badges.
BLENDER_ID_BADGE_EXPIRY = datetime.timedelta(hours=4)
# Mapping from user role to capabilities obtained by users with that role.
USER_CAPABILITIES = defaultdict(**{
'subscriber': {'subscriber', 'home-project'},

View File

@@ -1,3 +1,5 @@
import flask
import raven.breadcrumbs
from raven.contrib.flask import Sentry
from .auth import current_user
@@ -14,16 +16,14 @@ class PillarSentry(Sentry):
def init_app(self, app, *args, **kwargs):
super().init_app(app, *args, **kwargs)
# We perform authentication of the user while handling the request,
# so Sentry calls get_user_info() too early.
flask.request_started.connect(self.__add_sentry_breadcrumbs, self)
def get_user_context_again(self, ):
from flask import request
try:
self.client.user_context(self.get_user_info(request))
except Exception as e:
self.client.logger.exception(str(e))
def __add_sentry_breadcrumbs(self, sender, **extra):
raven.breadcrumbs.record(
message='Request started',
category='http',
data={'url': flask.request.url}
)
def get_user_info(self, request):
user_info = super().get_user_info(request)

View File

@@ -230,7 +230,7 @@ class Attachment:
from pillar.web import system_util
attachments = node_properties.get('attachments', {})
attachments = node_properties.get('properties', {}).get('attachments', {})
attachment = attachments.get(slug)
if not attachment:
raise self.NoSuchSlug(slug)

View File

@@ -1,6 +1,7 @@
# -*- encoding: utf-8 -*-
import base64
import contextlib
import copy
import datetime
import json
@@ -10,11 +11,7 @@ import pathlib
import sys
import typing
import unittest.mock
try:
from urllib.parse import urlencode
except ImportError:
from urllib.parse import urlencode
from urllib.parse import urlencode, urljoin
from bson import ObjectId, tz_util
@@ -27,6 +24,7 @@ from eve.tests import TestMinimal
import pymongo.collection
from flask.testing import FlaskClient
import flask.ctx
import flask.wrappers
import responses
import pillar
@@ -185,7 +183,7 @@ class AbstractPillarTest(TestMinimal):
else:
self.ensure_project_exists()
with self.app.test_request_context():
with self.app.app_context():
files_collection = self.app.data.driver.db['files']
assert isinstance(files_collection, pymongo.collection.Collection)
@@ -326,15 +324,46 @@ class AbstractPillarTest(TestMinimal):
return user
def create_valid_auth_token(self, user_id, token='token'):
@contextlib.contextmanager
def login_as(self, user_id: typing.Union[str, ObjectId]):
"""Context manager, within the context the app context is active and the user logged in.
The logging-in happens when a request starts, so it's only active when
e.g. self.get() or self.post() or somesuch request is used.
"""
from pillar.auth import UserClass, login_user_object
if isinstance(user_id, str):
user_oid = ObjectId(user_id)
elif isinstance(user_id, ObjectId):
user_oid = user_id
else:
raise TypeError(f'invalid type {type(user_id)} for parameter user_id')
user_doc = self.fetch_user_from_db(user_oid)
def signal_handler(sender, **kwargs):
login_user_object(user)
with self.app.app_context():
user = UserClass.construct('', user_doc)
with flask.request_started.connected_to(signal_handler, self.app):
yield
# TODO: rename to 'create_auth_token' now that 'expire_in_days' can be negative.
def create_valid_auth_token(self,
user_id: ObjectId,
token='token',
*,
oauth_scopes: typing.Optional[typing.List[str]]=None,
expire_in_days=1) -> dict:
from pillar.api.utils import utcnow
future = utcnow() + datetime.timedelta(days=1)
future = utcnow() + datetime.timedelta(days=expire_in_days)
with self.app.test_request_context():
from pillar.api.utils import authentication as auth
token_data = auth.store_token(user_id, token, future, None)
token_data = auth.store_token(user_id, token, future, oauth_scopes=oauth_scopes)
return token_data
@@ -364,7 +393,7 @@ class AbstractPillarTest(TestMinimal):
return user_id
def create_node(self, node_doc):
def create_node(self, node_doc) -> ObjectId:
"""Creates a node, returning its ObjectId. """
with self.app.test_request_context():
@@ -406,7 +435,7 @@ class AbstractPillarTest(TestMinimal):
"""Sets up Responses to mock unhappy validation flow."""
responses.add(responses.POST,
'%s/u/validate_token' % self.app.config['BLENDER_ID_ENDPOINT'],
urljoin(self.app.config['BLENDER_ID_ENDPOINT'], 'u/validate_token'),
json={'status': 'fail'},
status=403)
@@ -414,7 +443,7 @@ class AbstractPillarTest(TestMinimal):
"""Sets up Responses to mock happy validation flow."""
responses.add(responses.POST,
'%s/u/validate_token' % self.app.config['BLENDER_ID_ENDPOINT'],
urljoin(self.app.config['BLENDER_ID_ENDPOINT'], 'u/validate_token'),
json=BLENDER_ID_USER_RESPONSE,
status=200)
@@ -485,11 +514,10 @@ class AbstractPillarTest(TestMinimal):
def client_request(self, method, path, qs=None, expected_status=200, auth_token=None, json=None,
data=None, headers=None, files=None, content_type=None, etag=None,
environ_overrides=None):
environ_overrides=None) -> flask.wrappers.Response:
"""Performs a HTTP request to the server."""
from pillar.api.utils import dumps
import json as mod_json
headers = headers or {}
environ_overrides = environ_overrides or {}
@@ -522,29 +550,21 @@ class AbstractPillarTest(TestMinimal):
expected_status, resp.status_code, resp.data
))
def get_json():
if resp.mimetype != 'application/json':
raise TypeError('Unable to load JSON from mimetype %r' % resp.mimetype)
return mod_json.loads(resp.data)
resp.json = get_json
resp.get_json = get_json
return resp
def get(self, *args, **kwargs):
def get(self, *args, **kwargs) -> flask.wrappers.Response:
return self.client_request('GET', *args, **kwargs)
def post(self, *args, **kwargs):
def post(self, *args, **kwargs) -> flask.wrappers.Response:
return self.client_request('POST', *args, **kwargs)
def put(self, *args, **kwargs):
def put(self, *args, **kwargs) -> flask.wrappers.Response:
return self.client_request('PUT', *args, **kwargs)
def delete(self, *args, **kwargs):
def delete(self, *args, **kwargs) -> flask.wrappers.Response:
return self.client_request('DELETE', *args, **kwargs)
def patch(self, *args, **kwargs):
def patch(self, *args, **kwargs) -> flask.wrappers.Response:
return self.client_request('PATCH', *args, **kwargs)
def assertAllowsAccess(self,
@@ -561,7 +581,7 @@ class AbstractPillarTest(TestMinimal):
raise TypeError('expected_user_id should be a string or ObjectId, '
f'but is {expected_user_id!r}')
resp = self.get('/api/users/me', expected_status=200, auth_token=token).json()
resp = self.get('/api/users/me', expected_status=200, auth_token=token).get_json()
if expected_user_id:
self.assertEqual(resp['_id'], str(expected_user_id))

View File

@@ -1,9 +1,9 @@
"""Flask configuration file for unit testing."""
BLENDER_ID_ENDPOINT = 'http://id.local:8001' # Non existant server
BLENDER_ID_ENDPOINT = 'http://id.local:8001/' # Non existant server
SERVER_NAME = 'localhost'
PILLAR_SERVER_ENDPOINT = 'http://localhost/api/'
SERVER_NAME = 'localhost.local'
PILLAR_SERVER_ENDPOINT = 'http://localhost.local/api/'
MAIN_PROJECT_ID = '5672beecc0261b2005ed1a33'
@@ -44,3 +44,5 @@ ELASTIC_INDICES = {
# MUST be 8 characters long, see pillar.flask_extra.HashedPathConverter
STATIC_FILE_HASH = 'abcd1234'
CACHE_NO_NULL_WARNING = True

View File

@@ -1,6 +1,7 @@
from pillar.api.eve_settings import *
MONGO_DBNAME = 'pillar_test'
MONGO_USERNAME = None
def override_eve():
@@ -10,5 +11,7 @@ def override_eve():
test_settings.MONGO_HOST = MONGO_HOST
test_settings.MONGO_PORT = MONGO_PORT
test_settings.MONGO_DBNAME = MONGO_DBNAME
test_settings.MONGO1_USERNAME = MONGO_USERNAME
tests.MONGO_HOST = MONGO_HOST
tests.MONGO_DBNAME = MONGO_DBNAME
tests.MONGO_USERNAME = MONGO_USERNAME

View File

@@ -21,7 +21,7 @@ def attachment_form_group_create(schema_prop):
def _attachment_build_single_field(schema_prop):
# Ugly hard-coded schema.
fake_schema = {
'slug': schema_prop['propertyschema'],
'slug': schema_prop['keyschema'],
'oid': schema_prop['valueschema']['schema']['oid'],
}
file_select_form_group = build_file_select_form(fake_schema)

View File

@@ -61,16 +61,10 @@ def posts_view(project_id=None, project_url=None, url=None, *, archive=False, pa
post.picture = get_file(post.picture, api=api)
post.url = url_for_node(node=post)
# Use the *_main_project.html template for the main blog
is_main_project = project_id == current_app.config['MAIN_PROJECT_ID']
main_project_template = '_main_project' if is_main_project else ''
main_project_template = '_main_project'
index_arch = 'archive' if archive else 'index'
template_path = f'nodes/custom/blog/{index_arch}{main_project_template}.html',
template_path = f'nodes/custom/blog/{index_arch}.html',
if url:
template_path = f'nodes/custom/post/view{main_project_template}.html',
post = Node.find_one({
'where': {'parent': blog._id, 'properties.url': url},
'embedded': {'node_type': 1, 'user': 1},
@@ -95,6 +89,7 @@ def posts_view(project_id=None, project_url=None, url=None, *, archive=False, pa
can_create_blog_posts = project.node_type_has_method('post', 'POST', api=api)
# Use functools.partial so we can later pass page=X.
is_main_project = project_id == current_app.config['MAIN_PROJECT_ID']
if is_main_project:
url_func = functools.partial(url_for, 'main.main_blog_archive')
else:
@@ -121,7 +116,7 @@ def posts_view(project_id=None, project_url=None, url=None, *, archive=False, pa
return render_template(
template_path,
blog=blog,
node=post,
node=post, # node is used by the generic comments rendering (see custom/_scripts.pug)
posts=posts._items,
posts_meta=pmeta,
more_posts_available=pmeta['total'] > pmeta['max_results'],

View File

@@ -94,6 +94,16 @@ def find_for_post(project, node):
url=node.properties.url)
@register_node_finder('page')
def find_for_page(project, node):
"""Returns the URL for a page."""
project_id = project['_id']
the_project = project_url(project_id, project=project)
return url_for('projects.view_node', project_url=the_project.url, node_id=node.properties.url)
def find_for_other(project, node):
"""Fallback: Assets, textures, and other node types.

View File

@@ -1,9 +1,10 @@
import functools
import logging
import typing
from datetime import datetime
from datetime import date
import pillarsdk
from flask import current_app
from flask_wtf import FlaskForm
from wtforms import StringField
from wtforms import DateField
@@ -17,6 +18,8 @@ from wtforms import DateTimeField
from wtforms import SelectMultipleField
from wtforms import FieldList
from wtforms.validators import DataRequired
from pillar import current_app
from pillar.web.utils import system_util
from pillar.web.utils.forms import FileSelectField
from pillar.web.utils.forms import CustomFormField
@@ -44,6 +47,13 @@ def iter_node_properties(node_type):
yield prop_name, prop_schema, prop_fschema
@functools.lru_cache(maxsize=1)
def tag_choices() -> typing.List[typing.Tuple[str, str]]:
"""Return (value, label) tuples for the NODE_TAGS config setting."""
tags = current_app.config.get('NODE_TAGS') or []
return [(tag, tag.title()) for tag in tags] # (value, label) tuples
def add_form_properties(form_class, node_type):
"""Add fields to a form based on the node and form schema provided.
:type node_schema: dict
@@ -60,7 +70,9 @@ def add_form_properties(form_class, node_type):
# Recursive call if detects a dict
field_type = schema_prop['type']
if field_type == 'dict':
if prop_name == 'tags' and field_type == 'list':
field = SelectMultipleField(choices=tag_choices())
elif field_type == 'dict':
assert prop_name == 'attachments'
field = attachments.attachment_form_group_create(schema_prop)
elif field_type == 'list':

View File

@@ -24,6 +24,7 @@ from pillar import current_app
from pillar.api.utils import utcnow
from pillar.web import system_util
from pillar.web import utils
from pillar.web.nodes import finders
from pillar.web.utils.jstree import jstree_get_children
import pillar.extension
@@ -302,6 +303,52 @@ def view(project_url):
'header_video_node': header_video_node})
def project_navigation_links(project, api) -> list:
"""Returns a list of nodes for the project, for top navigation display.
Args:
project: A Project object.
api: the api client credential.
Returns:
A list of links for the Project.
For example we display a link to the project blog if present, as well
as pages. The list is structured as follows:
[{'url': '/p/spring/about', 'label': 'About'},
{'url': '/p/spring/blog', 'label': 'Blog'}]
"""
links = []
# Fetch the blog
blog = Node.find_first({
'where': {'project': project._id, 'node_type': 'blog', '_deleted': {'$ne': True}},
'projection': {
'name': 1,
}
}, api=api)
if blog:
links.append({'url': finders.find_url_for_node(blog), 'label': blog.name})
# Fetch pages
pages = Node.all({
'where': {'project': project._id, 'node_type': 'page', '_deleted': {'$ne': True}},
'projection': {
'name': 1,
'properties.url': 1
}
}, api=api)
# Process the results and append the links to the list
for p in pages._items:
links.append({'url': finders.find_url_for_node(p), 'label': p.name})
return links
def render_project(project, api, extra_context=None, template_name=None):
project.picture_square = utils.get_file(project.picture_square, api=api)
project.picture_header = utils.get_file(project.picture_header, api=api)
@@ -370,6 +417,8 @@ def render_project(project, api, extra_context=None, template_name=None):
extension_sidebar_links = current_app.extension_sidebar_links(project)
navigation_links = project_navigation_links(project, api)
return render_template(template_name,
api=api,
project=project,
@@ -378,6 +427,7 @@ def render_project(project, api, extra_context=None, template_name=None):
show_project=True,
og_picture=project.picture_header,
activity_stream=activity_stream,
navigation_links=navigation_links,
extension_sidebar_links=extension_sidebar_links,
**extra_context)
@@ -447,16 +497,14 @@ def view_node(project_url, node_id):
# Append _theatre to load the proper template
theatre = '_theatre' if theatre_mode else ''
navigation_links = project_navigation_links(project, api)
if node.node_type == 'page':
pages = Node.all({
'where': {'project': project._id, 'node_type': 'page'},
'projection': {'name': 1}}, api=api)
return render_template('nodes/custom/page/view_embed.html',
api=api,
node=node,
project=project,
pages=pages._items,
navigation_links=navigation_links,
og_picture=og_picture,)
extension_sidebar_links = current_app.extension_sidebar_links(project)
@@ -468,6 +516,7 @@ def view_node(project_url, node_id):
show_node=True,
show_project=False,
og_picture=og_picture,
navigation_links=navigation_links,
extension_sidebar_links=extension_sidebar_links)

View File

@@ -872,12 +872,6 @@
"code": 61930,
"src": "fontawesome"
},
{
"uid": "31972e4e9d080eaa796290349ae6c1fd",
"css": "users",
"code": 59502,
"src": "fontawesome"
},
{
"uid": "c8585e1e5b0467f28b70bce765d5840c",
"css": "clipboard-copy",
@@ -990,6 +984,30 @@
"code": 59394,
"src": "entypo"
},
{
"uid": "347c38a8b96a509270fdcabc951e7571",
"css": "database",
"code": 61888,
"src": "fontawesome"
},
{
"uid": "3a6f0140c3a390bdb203f56d1bfdefcb",
"css": "speed",
"code": 59471,
"src": "entypo"
},
{
"uid": "4c1ef492f1d2c39a2250ae457cee2a6e",
"css": "social-instagram",
"code": 61805,
"src": "fontawesome"
},
{
"uid": "e36d581e4f2844db345bddc205d15dda",
"css": "users",
"code": 59507,
"src": "elusive"
},
{
"uid": "053a214a098a9453877363eeb45f004e",
"css": "log-in",

File diff suppressed because one or more lines are too long

View File

@@ -33,7 +33,8 @@ def get_user_info(user_id):
# TODO: put those fields into a config var or module-level global.
return {'email': user.email,
'full_name': user.full_name,
'username': user.username}
'username': user.username,
'badges_html': (user.badges and user.badges.html) or ''}
def setup_app(app):

View File

@@ -48,6 +48,10 @@ def oauth_authorize(provider):
@blueprint.route('/oauth/<provider>/authorized')
def oauth_callback(provider):
import datetime
from pillar.api.utils.authentication import store_token
from pillar.api.utils import utcnow
if current_user.is_authenticated:
return redirect(url_for('main.homepage'))
@@ -65,7 +69,17 @@ def oauth_callback(provider):
user_info = {'id': oauth_user.id, 'email': oauth_user.email, 'full_name': ''}
db_user = find_user_in_db(user_info, provider=provider)
db_id, status = upsert_user(db_user)
token = generate_and_store_token(db_id)
# TODO(Sybren): If the user doesn't have any badges, but the access token
# does have 'badge' scope, we should fetch the badges in the background.
if oauth_user.access_token:
# TODO(Sybren): make nr of days configurable, or get from OAuthSignIn subclass.
token_expiry = utcnow() + datetime.timedelta(days=15)
token = store_token(db_id, oauth_user.access_token, token_expiry,
oauth_scopes=oauth_user.scopes)
else:
token = generate_and_store_token(db_id)
# Login user
pillar.auth.login_user(token['token'], load_from_db=True)

View File

@@ -62,7 +62,7 @@ def jstree_get_children(node_id, project_id=None):
'where': {
'$and': [
{'node_type': {'$regex': '^(?!attract_)'}},
{'node_type': {'$not': {'$in': ['comment', 'post']}}},
{'node_type': {'$not': {'$in': ['comment', 'post', 'blog', 'page']}}},
],
}
}

View File

@@ -6,16 +6,16 @@ algoliasearch==1.12.0
bcrypt==3.1.3
blinker==1.4
bleach==2.1.3
celery[redis]==4.0.2
celery[redis]==4.2.1
CommonMark==0.7.2
elasticsearch==6.1.1
elasticsearch-dsl==6.1.0
Eve==0.7.3
Flask==0.12
Eve==0.8
Flask==1.0.2
Flask-Babel==0.11.2
Flask-Cache==0.13.1
Flask-Caching==1.4.0
Flask-Script==2.0.6
Flask-Login==0.3.2
Flask-Login==0.4.1
Flask-WTF==0.14.2
gcloud==0.12.0
google-apitools==0.4.11
@@ -27,37 +27,49 @@ Pillow==4.1.1
python-dateutil==2.5.3
rauth==0.7.3
raven[flask]==6.3.0
requests==2.13.0
redis==2.10.5
shortcodes==2.5.0
WebOb==1.5.0
wheel==0.29.0
zencoder==0.6.5
# Secondary requirements
amqp==2.1.4
billiard==3.5.0.2
Flask-PyMongo==0.4.1
-e git+https://github.com/armadillica/cerberus.git@sybren-0.9#egg=Cerberus
Events==0.2.2
future==0.15.2
html5lib==0.99999999
googleapis-common-protos==1.1.0
amqp==2.3.2
asn1crypto==0.24.0
Babel==2.6.0
billiard==3.5.0.4
Cerberus==1.2
cffi==1.10.0
click==6.7
cryptography==2.0.3
Events==0.3
future==0.16.0
googleapis-common-protos==1.5.3
html5lib==1.0.1
idna==2.5
ipaddress==1.0.22
itsdangerous==0.24
Jinja2==2.9.6
kombu==4.0.2
oauth2client==2.0.2
oauthlib==2.0.1
olefile==0.44
protobuf==3.0.0b2.post2
protorpc==0.11.1
pyasn1-modules==0.0.8
pymongo==3.4.0
pytz==2017.2
requests-oauthlib==0.7.0
Jinja2==2.10
kombu==4.2.1
oauth2client==4.1.2
oauthlib==2.1.0
olefile==0.45.1
protobuf==3.6.0
protorpc==0.12.0
pyasn1==0.4.4
pyasn1-modules==0.2.2
pycparser==2.17
pymongo==3.7.0
pyOpenSSL==16.2.0
pytz==2018.5
requests-oauthlib==1.0.0
rsa==3.4.2
simplejson==3.10.0
simplejson==3.16.0
six==1.10.0
urllib3==1.22
vine==1.1.3
WTForms==2.1
Werkzeug==0.11.15
vine==1.1.4
webencodings==0.5.1
Werkzeug==0.14.1
WTForms==2.2.1

View File

@@ -35,7 +35,7 @@ setuptools.setup(
install_requires=[
'Flask>=0.12',
'Eve>=0.7.3',
'Flask-Cache>=0.13.1',
'Flask-Caching>=1.4.0',
'Flask-Script>=2.0.5',
'Flask-Login>=0.3.2',
'Flask-OAuthlib>=0.9.3',

File diff suppressed because it is too large Load Diff

View File

@@ -1,116 +0,0 @@
(function () {
var output, Converter;
if (typeof exports === "object" && typeof require === "function") { // we're in a CommonJS (e.g. Node.js) module
output = exports;
Converter = require("./Markdown.Converter").Converter;
} else {
output = window.Markdown;
Converter = output.Converter;
}
output.getSanitizingConverter = function () {
var converter = new Converter();
converter.hooks.chain("postConversion", sanitizeHtml);
converter.hooks.chain("postConversion", balanceTags);
return converter;
}
function sanitizeHtml(html) {
return html.replace(/<[^>]*>?/gi, sanitizeTag);
}
// (tags that can be opened/closed) | (tags that stand alone)
var basic_tag_whitelist = /^(<\/?(b|blockquote|code|del|dd|dl|dt|em|h1|h2|h3|i|iframe|kbd|li|ol(?: start="\d+")?|p|pre|s|sup|sub|strong|strike|ul|video)>|<(br|hr)\s?\/?>)$/i;
// <a href="url..." optional title>|</a>
var a_white = /^(<a\shref="((https?|ftp):\/\/|\/)[-A-Za-z0-9+&@#\/%?=~_|!:,.;\(\)*[\]$]+"(\stitle="[^"<>]+")?(\sclass="[^"<>]+")?\s?>|<\/a>)$/i;
// Cloud custom: Allow iframe embed from YouTube, Vimeo and SoundCloud
var iframe_youtube = /^(<iframe(\swidth="\d{1,3}")?(\sheight="\d{1,3}")\ssrc="((https?):\/\/|\/)[-A-Za-z0-9+&@#\/%?=~_|!:,.;\(\)*[\]$]+"(\sframeborder="\d{1,3}")?(\sallowfullscreen)\s?>|<\/iframe>)$/i;
var iframe_vimeo = /^(<iframe(\ssrc="((https?):\/\/|\/)[-A-Za-z0-9+&@#\/%?=~_|!:,.;\(\)*[\]$]+"?\swidth="\d{1,3}")?(\sheight="\d{1,3}")?(\sframeborder="\d{1,3}")?(\swebkitallowfullscreen)\s?(\smozallowfullscreen)\s?(\sallowfullscreen)\s?>|<\/iframe>)$/i;
var iframe_soundcloud = /^(<iframe(\swidth="\d{1,3}\%")?(\sheight="\d{1,3}")?(\sscrolling="(?:yes|no)")?(\sframeborder="(?:yes|no)")\ssrc="((https?):\/\/|\/)[-A-Za-z0-9+&@#\/%?=~_|!:,.;\(\)*[\]$]+"\s?>|<\/iframe>)$/i;
var iframe_googlestorage = /^(<iframe\ssrc="https:\/\/storage.googleapis.com\/institute-storage\/.+"\sstyle=".*"\s?>|<\/iframe>)$/i;
// <img src="url..." optional width optional height optional alt optional title
var img_white = /^(<img\ssrc="(https?:\/\/|\/)[-A-Za-z0-9+&@#\/%?=~_|!:,.;\(\)*[\]$]+"(\swidth="\d{1,3}")?(\sheight="\d{1,3}")?(\salt="[^"<>]*")?(\stitle="[^"<>]*")?\s?\/?>)$/i;
var video_white = /<video(.*?)>/;
function sanitizeTag(tag) {
if (tag.match(basic_tag_whitelist) || tag.match(a_white) || tag.match(img_white) || tag.match(iframe_youtube) || tag.match(iframe_vimeo) || tag.match(iframe_soundcloud) || tag.match(iframe_googlestorage) || tag.match(video_white)) {
return tag;
} else {
return "";
}
}
/// <summary>
/// attempt to balance HTML tags in the html string
/// by removing any unmatched opening or closing tags
/// IMPORTANT: we *assume* HTML has *already* been
/// sanitized and is safe/sane before balancing!
///
/// adapted from CODESNIPPET: A8591DBA-D1D3-11DE-947C-BA5556D89593
/// </summary>
function balanceTags(html) {
if (html == "")
return "";
var re = /<\/?\w+[^>]*(\s|$|>)/g;
// convert everything to lower case; this makes
// our case insensitive comparisons easier
var tags = html.toLowerCase().match(re);
// no HTML tags present? nothing to do; exit now
var tagcount = (tags || []).length;
if (tagcount == 0)
return html;
var tagname, tag;
var ignoredtags = "<p><img><br><li><hr>";
var match;
var tagpaired = [];
var tagremove = [];
var needsRemoval = false;
// loop through matched tags in forward order
for (var ctag = 0; ctag < tagcount; ctag++) {
tagname = tags[ctag].replace(/<\/?(\w+).*/, "$1");
// skip any already paired tags
// and skip tags in our ignore list; assume they're self-closed
if (tagpaired[ctag] || ignoredtags.search("<" + tagname + ">") > -1)
continue;
tag = tags[ctag];
match = -1;
if (!/^<\//.test(tag)) {
// this is an opening tag
// search forwards (next tags), look for closing tags
for (var ntag = ctag + 1; ntag < tagcount; ntag++) {
if (!tagpaired[ntag] && tags[ntag] == "</" + tagname + ">") {
match = ntag;
break;
}
}
}
if (match == -1)
needsRemoval = tagremove[ctag] = true; // mark for removal
else
tagpaired[match] = true; // mark paired
}
if (!needsRemoval)
return html;
// delete all orphaned tags from the string
var ctag = 0;
html = html.replace(re, function (match) {
var res = tagremove[ctag] ? "" : match;
ctag++;
return res;
});
return html;
}
})();

File diff suppressed because it is too large Load Diff

View File

@@ -1,874 +0,0 @@
(function () {
// A quick way to make sure we're only keeping span-level tags when we need to.
// This isn't supposed to be foolproof. It's just a quick way to make sure we
// keep all span-level tags returned by a pagedown converter. It should allow
// all span-level tags through, with or without attributes.
var inlineTags = new RegExp(['^(<\\/?(a|abbr|acronym|applet|area|b|basefont|',
'bdo|big|button|cite|code|del|dfn|em|figcaption|',
'font|i|iframe|img|input|ins|kbd|label|map|',
'mark|meter|object|param|progress|q|ruby|rp|rt|s|',
'samp|script|select|small|span|strike|strong|',
'sub|sup|textarea|time|tt|u|var|wbr)[^>]*>|',
'<(br)\\s?\\/?>)$'].join(''), 'i');
/******************************************************************
* Utility Functions *
*****************************************************************/
// patch for ie7
if (!Array.indexOf) {
Array.prototype.indexOf = function(obj) {
for (var i = 0; i < this.length; i++) {
if (this[i] == obj) {
return i;
}
}
return -1;
};
}
function trim(str) {
return str.replace(/^\s+|\s+$/g, '');
}
function rtrim(str) {
return str.replace(/\s+$/g, '');
}
// Remove one level of indentation from text. Indent is 4 spaces.
function outdent(text) {
return text.replace(new RegExp('^(\\t|[ ]{1,4})', 'gm'), '');
}
function contains(str, substr) {
return str.indexOf(substr) != -1;
}
// Sanitize html, removing tags that aren't in the whitelist
function sanitizeHtml(html, whitelist) {
return html.replace(/<[^>]*>?/gi, function(tag) {
return tag.match(whitelist) ? tag : '';
});
}
// Merge two arrays, keeping only unique elements.
function union(x, y) {
var obj = {};
for (var i = 0; i < x.length; i++)
obj[x[i]] = x[i];
for (i = 0; i < y.length; i++)
obj[y[i]] = y[i];
var res = [];
for (var k in obj) {
if (obj.hasOwnProperty(k))
res.push(obj[k]);
}
return res;
}
// JS regexes don't support \A or \Z, so we add sentinels, as Pagedown
// does. In this case, we add the ascii codes for start of text (STX) and
// end of text (ETX), an idea borrowed from:
// https://github.com/tanakahisateru/js-markdown-extra
function addAnchors(text) {
if(text.charAt(0) != '\x02')
text = '\x02' + text;
if(text.charAt(text.length - 1) != '\x03')
text = text + '\x03';
return text;
}
// Remove STX and ETX sentinels.
function removeAnchors(text) {
if(text.charAt(0) == '\x02')
text = text.substr(1);
if(text.charAt(text.length - 1) == '\x03')
text = text.substr(0, text.length - 1);
return text;
}
// Convert markdown within an element, retaining only span-level tags
function convertSpans(text, extra) {
return sanitizeHtml(convertAll(text, extra), inlineTags);
}
// Convert internal markdown using the stock pagedown converter
function convertAll(text, extra) {
var result = extra.blockGamutHookCallback(text);
// We need to perform these operations since we skip the steps in the converter
result = unescapeSpecialChars(result);
result = result.replace(/~D/g, "$$").replace(/~T/g, "~");
result = extra.previousPostConversion(result);
return result;
}
// Convert escaped special characters
function processEscapesStep1(text) {
// Markdown extra adds two escapable characters, `:` and `|`
return text.replace(/\\\|/g, '~I').replace(/\\:/g, '~i');
}
function processEscapesStep2(text) {
return text.replace(/~I/g, '|').replace(/~i/g, ':');
}
// Duplicated from PageDown converter
function unescapeSpecialChars(text) {
// Swap back in all the special characters we've hidden.
text = text.replace(/~E(\d+)E/g, function(wholeMatch, m1) {
var charCodeToReplace = parseInt(m1);
return String.fromCharCode(charCodeToReplace);
});
return text;
}
function slugify(text) {
return text.toLowerCase()
.replace(/\s+/g, '-') // Replace spaces with -
.replace(/[^\w\-]+/g, '') // Remove all non-word chars
.replace(/\-\-+/g, '-') // Replace multiple - with single -
.replace(/^-+/, '') // Trim - from start of text
.replace(/-+$/, ''); // Trim - from end of text
}
/*****************************************************************************
* Markdown.Extra *
****************************************************************************/
Markdown.Extra = function() {
// For converting internal markdown (in tables for instance).
// This is necessary since these methods are meant to be called as
// preConversion hooks, and the Markdown converter passed to init()
// won't convert any markdown contained in the html tags we return.
this.converter = null;
// Stores html blocks we generate in hooks so that
// they're not destroyed if the user is using a sanitizing converter
this.hashBlocks = [];
// Stores footnotes
this.footnotes = {};
this.usedFootnotes = [];
// Special attribute blocks for fenced code blocks and headers enabled.
this.attributeBlocks = false;
// Fenced code block options
this.googleCodePrettify = false;
this.highlightJs = false;
// Table options
this.tableClass = '';
this.tabWidth = 4;
};
Markdown.Extra.init = function(converter, options) {
// Each call to init creates a new instance of Markdown.Extra so it's
// safe to have multiple converters, with different options, on a single page
var extra = new Markdown.Extra();
var postNormalizationTransformations = [];
var preBlockGamutTransformations = [];
var postSpanGamutTransformations = [];
var postConversionTransformations = ["unHashExtraBlocks"];
options = options || {};
options.extensions = options.extensions || ["all"];
if (contains(options.extensions, "all")) {
options.extensions = ["tables", "fenced_code_gfm", "def_list", "attr_list", "footnotes", "smartypants", "strikethrough", "newlines"];
}
preBlockGamutTransformations.push("wrapHeaders");
if (contains(options.extensions, "attr_list")) {
postNormalizationTransformations.push("hashFcbAttributeBlocks");
preBlockGamutTransformations.push("hashHeaderAttributeBlocks");
postConversionTransformations.push("applyAttributeBlocks");
extra.attributeBlocks = true;
}
if (contains(options.extensions, "fenced_code_gfm")) {
// This step will convert fcb inside list items and blockquotes
preBlockGamutTransformations.push("fencedCodeBlocks");
// This extra step is to prevent html blocks hashing and link definition/footnotes stripping inside fcb
postNormalizationTransformations.push("fencedCodeBlocks");
}
if (contains(options.extensions, "tables")) {
preBlockGamutTransformations.push("tables");
}
if (contains(options.extensions, "def_list")) {
preBlockGamutTransformations.push("definitionLists");
}
if (contains(options.extensions, "footnotes")) {
postNormalizationTransformations.push("stripFootnoteDefinitions");
preBlockGamutTransformations.push("doFootnotes");
postConversionTransformations.push("printFootnotes");
}
if (contains(options.extensions, "smartypants")) {
postConversionTransformations.push("runSmartyPants");
}
if (contains(options.extensions, "strikethrough")) {
postSpanGamutTransformations.push("strikethrough");
}
if (contains(options.extensions, "newlines")) {
postSpanGamutTransformations.push("newlines");
}
converter.hooks.chain("postNormalization", function(text) {
return extra.doTransform(postNormalizationTransformations, text) + '\n';
});
converter.hooks.chain("preBlockGamut", function(text, blockGamutHookCallback) {
// Keep a reference to the block gamut callback to run recursively
extra.blockGamutHookCallback = blockGamutHookCallback;
text = processEscapesStep1(text);
text = extra.doTransform(preBlockGamutTransformations, text) + '\n';
text = processEscapesStep2(text);
return text;
});
converter.hooks.chain("postSpanGamut", function(text) {
return extra.doTransform(postSpanGamutTransformations, text);
});
// Keep a reference to the hook chain running before doPostConversion to apply on hashed extra blocks
extra.previousPostConversion = converter.hooks.postConversion;
converter.hooks.chain("postConversion", function(text) {
text = extra.doTransform(postConversionTransformations, text);
// Clear state vars that may use unnecessary memory
extra.hashBlocks = [];
extra.footnotes = {};
extra.usedFootnotes = [];
return text;
});
if ("highlighter" in options) {
extra.googleCodePrettify = options.highlighter === 'prettify';
extra.highlightJs = options.highlighter === 'highlight';
}
if ("table_class" in options) {
extra.tableClass = options.table_class;
}
extra.converter = converter;
// Caller usually won't need this, but it's handy for testing.
return extra;
};
// Do transformations
Markdown.Extra.prototype.doTransform = function(transformations, text) {
for(var i = 0; i < transformations.length; i++)
text = this[transformations[i]](text);
return text;
};
// Return a placeholder containing a key, which is the block's index in the
// hashBlocks array. We wrap our output in a <p> tag here so Pagedown won't.
Markdown.Extra.prototype.hashExtraBlock = function(block) {
return '\n<p>~X' + (this.hashBlocks.push(block) - 1) + 'X</p>\n';
};
Markdown.Extra.prototype.hashExtraInline = function(block) {
return '~X' + (this.hashBlocks.push(block) - 1) + 'X';
};
// Replace placeholder blocks in `text` with their corresponding
// html blocks in the hashBlocks array.
Markdown.Extra.prototype.unHashExtraBlocks = function(text) {
var self = this;
function recursiveUnHash() {
var hasHash = false;
text = text.replace(/(?:<p>)?~X(\d+)X(?:<\/p>)?/g, function(wholeMatch, m1) {
hasHash = true;
var key = parseInt(m1, 10);
return self.hashBlocks[key];
});
if(hasHash === true) {
recursiveUnHash();
}
}
recursiveUnHash();
return text;
};
// Wrap headers to make sure they won't be in def lists
Markdown.Extra.prototype.wrapHeaders = function(text) {
function wrap(text) {
return '\n' + text + '\n';
}
text = text.replace(/^.+[ \t]*\n=+[ \t]*\n+/gm, wrap);
text = text.replace(/^.+[ \t]*\n-+[ \t]*\n+/gm, wrap);
text = text.replace(/^\#{1,6}[ \t]*.+?[ \t]*\#*\n+/gm, wrap);
return text;
};
/******************************************************************
* Attribute Blocks *
*****************************************************************/
// TODO: use sentinels. Should we just add/remove them in doConversion?
// TODO: better matches for id / class attributes
var attrBlock = "\\{[ \\t]*((?:[#.][-_:a-zA-Z0-9]+[ \\t]*)+)\\}";
var hdrAttributesA = new RegExp("^(#{1,6}.*#{0,6})[ \\t]+" + attrBlock + "[ \\t]*(?:\\n|0x03)", "gm");
var hdrAttributesB = new RegExp("^(.*)[ \\t]+" + attrBlock + "[ \\t]*\\n" +
"(?=[\\-|=]+\\s*(?:\\n|0x03))", "gm"); // underline lookahead
var fcbAttributes = new RegExp("^(```[^`\\n]*)[ \\t]+" + attrBlock + "[ \\t]*\\n" +
"(?=([\\s\\S]*?)\\n```[ \\t]*(\\n|0x03))", "gm");
// Extract headers attribute blocks, move them above the element they will be
// applied to, and hash them for later.
Markdown.Extra.prototype.hashHeaderAttributeBlocks = function(text) {
var self = this;
function attributeCallback(wholeMatch, pre, attr) {
return '<p>~XX' + (self.hashBlocks.push(attr) - 1) + 'XX</p>\n' + pre + "\n";
}
text = text.replace(hdrAttributesA, attributeCallback); // ## headers
text = text.replace(hdrAttributesB, attributeCallback); // underline headers
return text;
};
// Extract FCB attribute blocks, move them above the element they will be
// applied to, and hash them for later.
Markdown.Extra.prototype.hashFcbAttributeBlocks = function(text) {
// TODO: use sentinels. Should we just add/remove them in doConversion?
// TODO: better matches for id / class attributes
var self = this;
function attributeCallback(wholeMatch, pre, attr) {
return '<p>~XX' + (self.hashBlocks.push(attr) - 1) + 'XX</p>\n' + pre + "\n";
}
return text.replace(fcbAttributes, attributeCallback);
};
Markdown.Extra.prototype.applyAttributeBlocks = function(text) {
var self = this;
var blockRe = new RegExp('<p>~XX(\\d+)XX</p>[\\s]*' +
'(?:<(h[1-6]|pre)(?: +class="(\\S+)")?(>[\\s\\S]*?</\\2>))', "gm");
text = text.replace(blockRe, function(wholeMatch, k, tag, cls, rest) {
if (!tag) // no following header or fenced code block.
return '';
// get attributes list from hash
var key = parseInt(k, 10);
var attributes = self.hashBlocks[key];
// get id
var id = attributes.match(/#[^\s#.]+/g) || [];
var idStr = id[0] ? ' id="' + id[0].substr(1, id[0].length - 1) + '"' : '';
// get classes and merge with existing classes
var classes = attributes.match(/\.[^\s#.]+/g) || [];
for (var i = 0; i < classes.length; i++) // Remove leading dot
classes[i] = classes[i].substr(1, classes[i].length - 1);
var classStr = '';
if (cls)
classes = union(classes, [cls]);
if (classes.length > 0)
classStr = ' class="' + classes.join(' ') + '"';
return "<" + tag + idStr + classStr + rest;
});
return text;
};
/******************************************************************
* Tables *
*****************************************************************/
// Find and convert Markdown Extra tables into html.
Markdown.Extra.prototype.tables = function(text) {
var self = this;
var leadingPipe = new RegExp(
['^' ,
'[ ]{0,3}' , // Allowed whitespace
'[|]' , // Initial pipe
'(.+)\\n' , // $1: Header Row
'[ ]{0,3}' , // Allowed whitespace
'[|]([ ]*[-:]+[-| :]*)\\n' , // $2: Separator
'(' , // $3: Table Body
'(?:[ ]*[|].*\\n?)*' , // Table rows
')',
'(?:\\n|$)' // Stop at final newline
].join(''),
'gm'
);
var noLeadingPipe = new RegExp(
['^' ,
'[ ]{0,3}' , // Allowed whitespace
'(\\S.*[|].*)\\n' , // $1: Header Row
'[ ]{0,3}' , // Allowed whitespace
'([-:]+[ ]*[|][-| :]*)\\n' , // $2: Separator
'(' , // $3: Table Body
'(?:.*[|].*\\n?)*' , // Table rows
')' ,
'(?:\\n|$)' // Stop at final newline
].join(''),
'gm'
);
text = text.replace(leadingPipe, doTable);
text = text.replace(noLeadingPipe, doTable);
// $1 = header, $2 = separator, $3 = body
function doTable(match, header, separator, body, offset, string) {
// remove any leading pipes and whitespace
header = header.replace(/^ *[|]/m, '');
separator = separator.replace(/^ *[|]/m, '');
body = body.replace(/^ *[|]/gm, '');
// remove trailing pipes and whitespace
header = header.replace(/[|] *$/m, '');
separator = separator.replace(/[|] *$/m, '');
body = body.replace(/[|] *$/gm, '');
// determine column alignments
var alignspecs = separator.split(/ *[|] */);
var align = [];
for (var i = 0; i < alignspecs.length; i++) {
var spec = alignspecs[i];
if (spec.match(/^ *-+: *$/m))
align[i] = ' align="right"';
else if (spec.match(/^ *:-+: *$/m))
align[i] = ' align="center"';
else if (spec.match(/^ *:-+ *$/m))
align[i] = ' align="left"';
else align[i] = '';
}
// TODO: parse spans in header and rows before splitting, so that pipes
// inside of tags are not interpreted as separators
var headers = header.split(/ *[|] */);
var colCount = headers.length;
// build html
var cls = self.tableClass ? ' class="' + self.tableClass + '"' : '';
var html = ['<table', cls, '>\n', '<thead>\n', '<tr>\n'].join('');
// build column headers.
for (i = 0; i < colCount; i++) {
var headerHtml = convertSpans(trim(headers[i]), self);
html += [" <th", align[i], ">", headerHtml, "</th>\n"].join('');
}
html += "</tr>\n</thead>\n";
// build rows
var rows = body.split('\n');
for (i = 0; i < rows.length; i++) {
if (rows[i].match(/^\s*$/)) // can apply to final row
continue;
// ensure number of rowCells matches colCount
var rowCells = rows[i].split(/ *[|] */);
var lenDiff = colCount - rowCells.length;
for (var j = 0; j < lenDiff; j++)
rowCells.push('');
html += "<tr>\n";
for (j = 0; j < colCount; j++) {
var colHtml = convertSpans(trim(rowCells[j]), self);
html += [" <td", align[j], ">", colHtml, "</td>\n"].join('');
}
html += "</tr>\n";
}
html += "</table>\n";
// replace html with placeholder until postConversion step
return self.hashExtraBlock(html);
}
return text;
};
/******************************************************************
* Footnotes *
*****************************************************************/
// Strip footnote, store in hashes.
Markdown.Extra.prototype.stripFootnoteDefinitions = function(text) {
var self = this;
text = text.replace(
/\n[ ]{0,3}\[\^(.+?)\]\:[ \t]*\n?([\s\S]*?)\n{1,2}((?=\n[ ]{0,3}\S)|$)/g,
function(wholeMatch, m1, m2) {
m1 = slugify(m1);
m2 += "\n";
m2 = m2.replace(/^[ ]{0,3}/g, "");
self.footnotes[m1] = m2;
return "\n";
});
return text;
};
// Find and convert footnotes references.
Markdown.Extra.prototype.doFootnotes = function(text) {
var self = this;
if(self.isConvertingFootnote === true) {
return text;
}
var footnoteCounter = 0;
text = text.replace(/\[\^(.+?)\]/g, function(wholeMatch, m1) {
var id = slugify(m1);
var footnote = self.footnotes[id];
if (footnote === undefined) {
return wholeMatch;
}
footnoteCounter++;
self.usedFootnotes.push(id);
var html = '<a href="#fn:' + id + '" id="fnref:' + id
+ '" title="See footnote" class="footnote">' + footnoteCounter
+ '</a>';
return self.hashExtraInline(html);
});
return text;
};
// Print footnotes at the end of the document
Markdown.Extra.prototype.printFootnotes = function(text) {
var self = this;
if (self.usedFootnotes.length === 0) {
return text;
}
text += '\n\n<div class="footnotes">\n<hr>\n<ol>\n\n';
for(var i=0; i<self.usedFootnotes.length; i++) {
var id = self.usedFootnotes[i];
var footnote = self.footnotes[id];
self.isConvertingFootnote = true;
var formattedfootnote = convertSpans(footnote, self);
delete self.isConvertingFootnote;
text += '<li id="fn:'
+ id
+ '">'
+ formattedfootnote
+ ' <a href="#fnref:'
+ id
+ '" title="Return to article" class="reversefootnote">&#8617;</a></li>\n\n';
}
text += '</ol>\n</div>';
return text;
};
/******************************************************************
* Fenced Code Blocks (gfm) *
******************************************************************/
// Find and convert gfm-inspired fenced code blocks into html.
Markdown.Extra.prototype.fencedCodeBlocks = function(text) {
function encodeCode(code) {
code = code.replace(/&/g, "&amp;");
code = code.replace(/</g, "&lt;");
code = code.replace(/>/g, "&gt;");
// These were escaped by PageDown before postNormalization
code = code.replace(/~D/g, "$$");
code = code.replace(/~T/g, "~");
return code;
}
var self = this;
text = text.replace(/(?:^|\n)```([^`\n]*)\n([\s\S]*?)\n```[ \t]*(?=\n)/g, function(match, m1, m2) {
var language = trim(m1), codeblock = m2;
// adhere to specified options
var preclass = self.googleCodePrettify ? ' class="prettyprint"' : '';
var codeclass = '';
if (language) {
if (self.googleCodePrettify || self.highlightJs) {
// use html5 language- class names. supported by both prettify and highlight.js
codeclass = ' class="language-' + language + '"';
} else {
codeclass = ' class="' + language + '"';
}
}
var html = ['<pre', preclass, '><code', codeclass, '>',
encodeCode(codeblock), '</code></pre>'].join('');
// replace codeblock with placeholder until postConversion step
return self.hashExtraBlock(html);
});
return text;
};
/******************************************************************
* SmartyPants *
******************************************************************/
Markdown.Extra.prototype.educatePants = function(text) {
var self = this;
var result = '';
var blockOffset = 0;
// Here we parse HTML in a very bad manner
text.replace(/(?:<!--[\s\S]*?-->)|(<)([a-zA-Z1-6]+)([^\n]*?>)([\s\S]*?)(<\/\2>)/g, function(wholeMatch, m1, m2, m3, m4, m5, offset) {
var token = text.substring(blockOffset, offset);
result += self.applyPants(token);
self.smartyPantsLastChar = result.substring(result.length - 1);
blockOffset = offset + wholeMatch.length;
if(!m1) {
// Skip commentary
result += wholeMatch;
return;
}
// Skip special tags
if(!/code|kbd|pre|script|noscript|iframe|math|ins|del|pre/i.test(m2)) {
m4 = self.educatePants(m4);
}
else {
self.smartyPantsLastChar = m4.substring(m4.length - 1);
}
result += m1 + m2 + m3 + m4 + m5;
});
var lastToken = text.substring(blockOffset);
result += self.applyPants(lastToken);
self.smartyPantsLastChar = result.substring(result.length - 1);
return result;
};
function revertPants(wholeMatch, m1) {
var blockText = m1;
blockText = blockText.replace(/&\#8220;/g, "\"");
blockText = blockText.replace(/&\#8221;/g, "\"");
blockText = blockText.replace(/&\#8216;/g, "'");
blockText = blockText.replace(/&\#8217;/g, "'");
blockText = blockText.replace(/&\#8212;/g, "---");
blockText = blockText.replace(/&\#8211;/g, "--");
blockText = blockText.replace(/&\#8230;/g, "...");
return blockText;
}
Markdown.Extra.prototype.applyPants = function(text) {
// Dashes
text = text.replace(/---/g, "&#8212;").replace(/--/g, "&#8211;");
// Ellipses
text = text.replace(/\.\.\./g, "&#8230;").replace(/\.\s\.\s\./g, "&#8230;");
// Backticks
text = text.replace(/``/g, "&#8220;").replace (/''/g, "&#8221;");
if(/^'$/.test(text)) {
// Special case: single-character ' token
if(/\S/.test(this.smartyPantsLastChar)) {
return "&#8217;";
}
return "&#8216;";
}
if(/^"$/.test(text)) {
// Special case: single-character " token
if(/\S/.test(this.smartyPantsLastChar)) {
return "&#8221;";
}
return "&#8220;";
}
// Special case if the very first character is a quote
// followed by punctuation at a non-word-break. Close the quotes by brute force:
text = text.replace (/^'(?=[!"#\$\%'()*+,\-.\/:;<=>?\@\[\\]\^_`{|}~]\B)/, "&#8217;");
text = text.replace (/^"(?=[!"#\$\%'()*+,\-.\/:;<=>?\@\[\\]\^_`{|}~]\B)/, "&#8221;");
// Special case for double sets of quotes, e.g.:
// <p>He said, "'Quoted' words in a larger quote."</p>
text = text.replace(/"'(?=\w)/g, "&#8220;&#8216;");
text = text.replace(/'"(?=\w)/g, "&#8216;&#8220;");
// Special case for decade abbreviations (the '80s):
text = text.replace(/'(?=\d{2}s)/g, "&#8217;");
// Get most opening single quotes:
text = text.replace(/(\s|&nbsp;|--|&[mn]dash;|&\#8211;|&\#8212;|&\#x201[34];)'(?=\w)/g, "$1&#8216;");
// Single closing quotes:
text = text.replace(/([^\s\[\{\(\-])'/g, "$1&#8217;");
text = text.replace(/'(?=\s|s\b)/g, "&#8217;");
// Any remaining single quotes should be opening ones:
text = text.replace(/'/g, "&#8216;");
// Get most opening double quotes:
text = text.replace(/(\s|&nbsp;|--|&[mn]dash;|&\#8211;|&\#8212;|&\#x201[34];)"(?=\w)/g, "$1&#8220;");
// Double closing quotes:
text = text.replace(/([^\s\[\{\(\-])"/g, "$1&#8221;");
text = text.replace(/"(?=\s)/g, "&#8221;");
// Any remaining quotes should be opening ones.
text = text.replace(/"/ig, "&#8220;");
return text;
};
// Find and convert markdown extra definition lists into html.
Markdown.Extra.prototype.runSmartyPants = function(text) {
this.smartyPantsLastChar = '';
text = this.educatePants(text);
// Clean everything inside html tags (some of them may have been converted due to our rough html parsing)
text = text.replace(/(<([a-zA-Z1-6]+)\b([^\n>]*?)(\/)?>)/g, revertPants);
return text;
};
/******************************************************************
* Definition Lists *
******************************************************************/
// Find and convert markdown extra definition lists into html.
Markdown.Extra.prototype.definitionLists = function(text) {
var wholeList = new RegExp(
['(\\x02\\n?|\\n\\n)' ,
'(?:' ,
'(' , // $1 = whole list
'(' , // $2
'[ ]{0,3}' ,
'((?:[ \\t]*\\S.*\\n)+)', // $3 = defined term
'\\n?' ,
'[ ]{0,3}:[ ]+' , // colon starting definition
')' ,
'([\\s\\S]+?)' ,
'(' , // $4
'(?=\\0x03)' , // \z
'|' ,
'(?=' ,
'\\n{2,}' ,
'(?=\\S)' ,
'(?!' , // Negative lookahead for another term
'[ ]{0,3}' ,
'(?:\\S.*\\n)+?' , // defined term
'\\n?' ,
'[ ]{0,3}:[ ]+' , // colon starting definition
')' ,
'(?!' , // Negative lookahead for another definition
'[ ]{0,3}:[ ]+' , // colon starting definition
')' ,
')' ,
')' ,
')' ,
')'
].join(''),
'gm'
);
var self = this;
text = addAnchors(text);
text = text.replace(wholeList, function(match, pre, list) {
var result = trim(self.processDefListItems(list));
result = "<dl>\n" + result + "\n</dl>";
return pre + self.hashExtraBlock(result) + "\n\n";
});
return removeAnchors(text);
};
// Process the contents of a single definition list, splitting it
// into individual term and definition list items.
Markdown.Extra.prototype.processDefListItems = function(listStr) {
var self = this;
var dt = new RegExp(
['(\\x02\\n?|\\n\\n+)' , // leading line
'(' , // definition terms = $1
'[ ]{0,3}' , // leading whitespace
'(?![:][ ]|[ ])' , // negative lookahead for a definition
// mark (colon) or more whitespace
'(?:\\S.*\\n)+?' , // actual term (not whitespace)
')' ,
'(?=\\n?[ ]{0,3}:[ ])' // lookahead for following line feed
].join(''), // with a definition mark
'gm'
);
var dd = new RegExp(
['\\n(\\n+)?' , // leading line = $1
'(' , // marker space = $2
'[ ]{0,3}' , // whitespace before colon
'[:][ ]+' , // definition mark (colon)
')' ,
'([\\s\\S]+?)' , // definition text = $3
'(?=\\n*' , // stop at next definition mark,
'(?:' , // next term or end of text
'\\n[ ]{0,3}[:][ ]|' ,
'<dt>|\\x03' , // \z
')' ,
')'
].join(''),
'gm'
);
listStr = addAnchors(listStr);
// trim trailing blank lines:
listStr = listStr.replace(/\n{2,}(?=\\x03)/, "\n");
// Process definition terms.
listStr = listStr.replace(dt, function(match, pre, termsStr) {
var terms = trim(termsStr).split("\n");
var text = '';
for (var i = 0; i < terms.length; i++) {
var term = terms[i];
// process spans inside dt
term = convertSpans(trim(term), self);
text += "\n<dt>" + term + "</dt>";
}
return text + "\n";
});
// Process actual definitions.
listStr = listStr.replace(dd, function(match, leadingLine, markerSpace, def) {
if (leadingLine || def.match(/\n{2,}/)) {
// replace marker with the appropriate whitespace indentation
def = Array(markerSpace.length + 1).join(' ') + def;
// process markdown inside definition
// TODO?: currently doesn't apply extensions
def = outdent(def) + "\n\n";
def = "\n" + convertAll(def, self) + "\n";
} else {
// convert span-level markdown inside definition
def = rtrim(def);
def = convertSpans(outdent(def), self);
}
return "\n<dd>" + def + "</dd>\n";
});
return removeAnchors(listStr);
};
/***********************************************************
* Strikethrough *
************************************************************/
Markdown.Extra.prototype.strikethrough = function(text) {
// Pretty much duplicated from _DoItalicsAndBold
return text.replace(/([\W_]|^)~T~T(?=\S)([^\r]*?\S[\*_]*)~T~T([\W_]|$)/g,
"$1<del>$2</del>$3");
};
/***********************************************************
* New lines *
************************************************************/
Markdown.Extra.prototype.newlines = function(text) {
// We have to ignore already converted newlines and line breaks in sub-list items
return text.replace(/(<(?:br|\/li)>)?\n/g, function(wholeMatch, previousTag) {
return previousTag ? wholeMatch : " <br>\n";
});
};
})();

View File

@@ -32,7 +32,7 @@ var DocumentTitleAPI = {
};
/* Status Bar */
/* Status Bar * DEPRECATED * USE TOASTR INSTEAD */
function statusBarClear(delay_class, delay_html){
var statusBar = $("#status-bar");
@@ -54,6 +54,7 @@ function statusBarClear(delay_class, delay_html){
}
}
/* Status Bar * DEPRECATED - USE TOASTR INSTEAD * */
function statusBarSet(classes, html, icon_name, time){
/* Utility to notify the user by temporarily flashing text on the project header
Usage:

View File

@@ -66,12 +66,9 @@ function containerResizeY(window_height){
var project_container = document.getElementById('project-container');
var container_offset = project_container.offsetTop;
var nav_header_height = $('#project_nav-header').height();
var container_height = window_height - container_offset.top;
var container_height_wheader = window_height - container_offset.top - nav_header_height;
var window_height_minus_nav = window_height - nav_header_height - 1; // 1 is border width
$('#project_context-header').width($('#project_context-container').width());
var container_height_wheader = window_height - container_offset;
var window_height_minus_nav = window_height - container_offset;
if ($(window).width() > 768) {
$('#project-container').css(
@@ -79,13 +76,14 @@ function containerResizeY(window_height){
'height': window_height_minus_nav + 'px'}
);
$('#project_nav-container, #project_tree, .project_split').css(
{'max-height': (window_height_minus_nav - 50) + 'px',
'height': (window_height_minus_nav - 50) + 'px'}
$('#project_nav-container, #project_tree').css(
{'max-height': (window_height_minus_nav) + 'px',
'height': (window_height_minus_nav) + 'px'}
);
if (container_height > parseInt($('#project-container').css("min-height"))) {
if (typeof projectTree !== "undefined"){
$(projectTree).css(
{'max-height': container_height_wheader + 'px',
'height': container_height_wheader + 'px'}

View File

@@ -0,0 +1,202 @@
/* Video.JS plugin for keeping track of user's viewing progress.
Also registers the analytics plugin.
Progress is reported after a number of seconds or a percentage
of the duration of the video, whichever comes first.
Example usage:
videojs(videoPlayerElement, options).ready(function() {
let report_url = '{{ url_for("users_api.set_video_progress", video_id=node._id) }}';
this.progressPlugin({'report_url': report_url});
});
*/
// Report after progressing this many seconds video-time.
let PROGRESS_REPORT_INTERVAL_SEC = 30;
// Report after progressing this percentage of the entire video (scale 0-100).
let PROGRESS_REPORT_INTERVAL_PERC = 10;
// Don't report within this many milliseconds of wall-clock time of the previous report.
let PROGRESS_RELAXING_TIME_MSEC = 500;
var Plugin = videojs.getPlugin('plugin');
var VideoProgressPlugin = videojs.extend(Plugin, {
constructor: function(player, options) {
Plugin.call(this, player, options);
this.last_wallclock_time_ms = 0;
this.last_inspected_progress_in_sec = 0;
this.last_reported_progress_in_sec = 0;
this.last_reported_progress_in_perc = 0;
this.report_url = options.report_url;
this.fetch_progress_url = options.fetch_progress_url;
this.reported_error = false;
this.reported_looping = false;
if (typeof this.report_url === 'undefined' || !this.report_url) {
/* If we can't report anything, don't bother registering event handlers. */
videojs.log('VideoProgressPlugin: no report_url option given. Not storing video progress.');
} else {
/* Those events will have 'this' bound to the player,
* which is why we explicitly re-bind to 'this''. */
player.on('timeupdate', this.on_timeupdate.bind(this));
player.on('pause', this.on_pause.bind(this));
}
if (typeof this.fetch_progress_url === 'undefined' || !this.fetch_progress_url) {
/* If we can't report anything, don't bother registering event handlers. */
videojs.log('VideoProgressPlugin: no fetch_progress_url option given. Not restoring video progress.');
} else {
this.resume_playback();
}
},
resume_playback: function() {
let on_done = function(progress, status, xhr) {
/* 'progress' is an object like:
{"progress_in_sec": 3,
"progress_in_percent": 51,
"last_watched": "Fri, 31 Aug 2018 13:53:06 GMT",
"done": true}
*/
switch (xhr.status) {
case 204: return; // no info found.
case 200:
/* Don't do anything when the progress is at 100%.
* Moving the current time to the end makes no sense then. */
if (progress.progress_in_percent >= 100) return;
/* Set the 'last reported' props before manipulating the
* player, so that the manipulation doesn't trigger more
* API calls to remember what we just restored. */
this.last_reported_progress_in_sec = progress.progress_in_sec;
this.last_reported_progress_in_perc = progress.progress_in_perc;
console.log("Continuing playback at ", progress.progress_in_percent, "% from", progress.last_watched);
this.player.currentTime(progress.progress_in_sec);
this.player.play();
return;
default:
console.log("Unknown code", xhr.status, "getting video progress information.");
}
};
$.get(this.fetch_progress_url)
.fail(function(error) {
console.log("Unable to fetch video progress information:", xhrErrorResponseMessage(error));
})
.done(on_done.bind(this));
},
/* Pausing playback should report the progress.
* This function is also called when playback stops at the end of the video,
* so it's important to report in this case; otherwise progress will never
* reach 100%. */
on_pause: function(event) {
this.inspect_progress(true);
},
on_timeupdate: function() {
this.inspect_progress(false);
},
inspect_progress: function(force_report) {
// Don't report seeking when paused, only report actual playback.
if (!force_report && this.player.paused()) return;
let now_in_ms = new Date().getTime();
if (!force_report && now_in_ms - this.last_wallclock_time_ms < PROGRESS_RELAXING_TIME_MSEC) {
// We're trying too fast, don't bother doing any other calculation.
// console.log('skipping, already reported', now_in_ms - this.last_wallclock_time_ms, 'ms ago.');
return;
}
let progress_in_sec = this.player.currentTime();
let duration_in_sec = this.player.duration();
/* Instead of reporting the current time, report reaching the end
* of the video. This ensures that it's properly marked as 'done'. */
if (!this.reported_looping) {
let margin = 1.25 * PROGRESS_RELAXING_TIME_MSEC / 1000.0;
let is_looping = progress_in_sec == 0 && duration_in_sec - this.last_inspected_progress_in_sec < margin;
this.last_inspected_progress_in_sec = progress_in_sec;
if (is_looping) {
this.reported_looping = true;
this.report(this.player.duration(), 100, now_in_ms);
return;
}
}
if (Math.abs(progress_in_sec - this.last_reported_progress_in_sec) < 0.01) {
// Already reported this, don't bother doing it again.
return;
}
let progress_in_perc = 100 * progress_in_sec / duration_in_sec;
let diff_sec = progress_in_sec - this.last_reported_progress_in_sec;
let diff_perc = progress_in_perc - this.last_reported_progress_in_perc;
if (!force_report
&& Math.abs(diff_perc) < PROGRESS_REPORT_INTERVAL_PERC
&& Math.abs(diff_sec) < PROGRESS_REPORT_INTERVAL_SEC) {
return;
}
this.report(progress_in_sec, progress_in_perc, now_in_ms);
},
report: function(progress_in_sec, progress_in_perc, now_in_ms) {
/* Store when we tried, not when we succeeded. This function can be
* called every 15-250 milliseconds, so we don't want to retry with
* that frequency. */
this.last_wallclock_time_ms = now_in_ms;
let on_fail = function(error) {
/* Don't show (as in: a toastr popup) the error to the user,
* as it doesn't impact their ability to play the video.
* Also show the error only once, instead of spamming. */
if (this.reported_error) return;
let msg = xhrErrorResponseMessage(error);
console.log('Unable to report viewing progress:', msg);
this.reported_error = true;
};
let on_done = function() {
this.last_reported_progress_in_sec = progress_in_sec;
this.last_reported_progress_in_perc = progress_in_perc;
};
$.post(this.report_url, {
progress_in_sec: progress_in_sec,
progress_in_perc: Math.round(progress_in_perc),
})
.fail(on_fail.bind(this))
.done(on_done.bind(this));
},
});
var RememberVolumePlugin = videojs.extend(Plugin, {
constructor: function(player, options) {
Plugin.call(this, player, options);
player.on('volumechange', this.on_volumechange.bind(this));
this.restore_volume();
},
restore_volume: function() {
let volume_str = localStorage.getItem('video-player-volume');
if (volume_str == null) return;
this.player.volume(1.0 * volume_str);
},
on_volumechange: function(event) {
localStorage.setItem('video-player-volume', this.player.volume());
},
});
// Register our watch-progress-bookkeeping plugin.
videojs.registerPlugin('progressPlugin', VideoProgressPlugin);
videojs.registerPlugin('rememberVolumePlugin', RememberVolumePlugin);

View File

@@ -143,12 +143,17 @@ nav.sidebar
left: 0
width: $sidebar-width
height: 100%
background-color: $color-background-nav
display: flex
flex-direction: column
> ul > li > .navbar-item
padding-top: 10px
padding-bottom: 10px
background: red
.dropdown
min-width: $sidebar-width
.dropdown-menu
top: initial
bottom: 3px
@@ -159,7 +164,7 @@ nav.sidebar
li a
justify-content: flex-start
ul
> ul
width: 100%
margin: 0
padding: 0
@@ -172,25 +177,11 @@ nav.sidebar
a.navbar-item, button
display: flex
color: $color-text-light-hint
font-size: 1.5em
align-items: center
justify-content: center
padding: 10px 0
background: transparent
border: none
width: 100%
text-decoration: none
&:hover
color: $color-text-light-primary
&:active
outline: none
&.cloud
i
position: relative
left: -4px
a.dropdown-toggle
padding: 0
@@ -408,3 +399,59 @@ nav.sidebar
top: -1px
left: -19px
z-index: 1
$loader-bar-width: 100px
$loader-bar-height: 2px
.loader-bar
bottom: 0
content: ''
display: none
height: 0
overflow: hidden
position: absolute
visibility: hidden
width: 100%
z-index: 20
&:before
animation: none
background-color: $primary
background-image: linear-gradient(to right, $primary-accent, $primary)
content: ''
display: block
height: $loader-bar-height
left: -$loader-bar-width
position: absolute
width: $loader-bar-width
&.active
display: block
height: $loader-bar-height
visibility: visible
&:before
animation: loader-bar-slide 2s linear infinite
@keyframes loader-bar-slide
from
left: -($loader-bar-width / 2)
width: 3%
50%
width: 20%
70%
width: 70%
80%
left: 50%
95%
left: 120%
to
left: 100%
.progress-bar
background-color: $primary
background-image: linear-gradient(to right, $primary-accent, $primary)

View File

@@ -1,6 +1,7 @@
$comments-width-max: 710px
.comments-container
max-width: $comments-width-max
position: relative
#comments-reload
@@ -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

View File

@@ -12,9 +12,10 @@ $color-background-active-dark: hsl(hue($color-background-active), 50%, 50%) !def
$font-body: 'Roboto' !default
$font-headings: 'Lato' !default
$font-size: 14px !default
$font-size-xs: .75rem
$font-size-xxs: .65rem
$color-text: #4d4e53 !default
$color-text-dark: $color-text !default
$color-text-dark-primary: #646469 !default
$color-text-dark-secondary: #9E9FA2 !default
@@ -29,6 +30,7 @@ $color-primary: #009eff !default
$color-primary-light: hsl(hue($color-primary), 30%, 90%) !default
$color-primary-dark: hsl(hue($color-primary), 80%, 30%) !default
$color-primary-accent: hsl(hue($color-primary), 100%, 50%) !default
$primary-accent: #0bd
$color-secondary: #f42942 !default
$color-secondary-light: hsl(hue($color-secondary), 30%, 90%) !default
@@ -96,13 +98,16 @@ $screen-xs-max: $screen-sm-min - 1 !default
$screen-sm-max: $screen-md-min - 1 !default
$screen-md-max: $screen-lg-min - 1 !default
$sidebar-width: 50px !default
$sidebar-width: 40px !default
/* Project specifics */
$project_nav-width: 250px !default
$project_nav-width: 275px !default
$project_nav-width-lg: $project_nav-width * 1.2 !default
$project_nav-width-md: $project_nav-width
$project_nav-width-sm: $project_nav-width * 0.8 !default
$project_nav-width-xs: 100% !default
$project-sidebar-width: 40px !default
$project_header-height: 37px !default
$project_footer-height: 30px !default
$project_header-height: 40px !default
$node-type-asset_image: #e87d86 !default
$node-type-asset_file: #CC91C7 !default
@@ -143,8 +148,14 @@ $font-size-base: .9rem
$dropdown-border-width: 0
$dropdown-box-shadow: 0 10px 25px rgba($black, .1)
$dropdown-padding-y: 0
$dropdown-item-padding-y: .4rem
// Tooltips.
$tooltip-font-size: 0.83rem
$tooltip-max-width: auto
$tooltip-opacity: 1
$nav-link-height: 37px
$navbar-padding-x: 0
$navbar-padding-y: 0

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
@@ -45,10 +48,10 @@
border-color: transparent transparent $color-background transparent
border-style: solid
border-width: 0 8px 8px 8px
bottom: -15px
bottom: -10px
height: 0
position: absolute
right: 22px
right: 7px
visibility: hidden
width: 0

View File

@@ -93,15 +93,6 @@ ul.sharing-users-list
color: lighten($color-danger, 10%)
.sharing-users-info
padding-left: 15px
border-left: thin solid $color-text-dark-hint
p
font:
size: 1.1em
weight: 300
.sharing-users-search
.disabled
color: $color-text-dark-secondary
@@ -158,24 +149,26 @@ ul.list-generic
list-style: none
> li
padding: 5px 0
display: flex
align-items: center
border-top: thin solid $color-background
display: flex
padding: 5px 0
&:first-child
border-top: none
&:hover .item a
color: $color-primary
color: $primary
a
flex: 1
&.active
color: $primary !important
font-weight: bold
.actions
margin-left: auto
.btn
font-size: .7em
span
color: $color-text-dark-secondary

View File

@@ -6,24 +6,12 @@ body.workshops
#project-container
+container-behavior
body.blog
background-color: white
#project_nav,
#project_nav-container
+media-md
width: $project_nav-width * 1.4
+media-sm
width: $project_nav-width * 1.1
+media-xs
width: 100%
width: $project_nav-width * 1.5
#project-container
display: flex
flex-direction: row
min-height: 300px
position: relative
overflow-y: auto
z-index: $z-index-base
+media-xs
@@ -36,14 +24,19 @@ body.blog
+media-xs
flex-direction: column-reverse
#project-side-container+#project_context-header
width: 100%
left: 0
#project_nav,
#project_nav-container
+media-lg
width: $project_nav-width * 1.33
width: $project_nav-width-lg
+media-sm
width: $project_nav-width-sm
+media-xs
width: $project_nav-width-xs
width: $project_nav-width
#project_nav-container
+media-xs
display: block
width: 100%
@@ -52,8 +45,6 @@ body.blog
position: fixed
z-index: $z-index-base + 5
width: $project_nav-width
#project_sidebar
width: $project-sidebar-width
@@ -80,24 +71,14 @@ body.blog
height: $project-sidebar-width
position: relative
+media-xs
border-bottom: 2px solid transparent
+media-sm
border-bottom: 2px solid transparent
+media-md
border-left: 2px solid transparent
+media-lg
border-left: 2px solid transparent
&:first-child
border-top: thin solid transparent
&:hover,
&.active
cursor: pointer
a
color: $primary
i
+active-gradient
a
align-items: center
@@ -112,13 +93,6 @@ body.blog
height: $project-sidebar-width
width: $project-sidebar-width
#project-loading
align-items: center
display: flex
height: $project-sidebar-width
justify-content: center
width: $project-sidebar-width
#search-container #project_sidebar ul.project-tabs li.tabs-thumbnail
background-color: $color-background-nav-dark
@@ -133,10 +107,11 @@ body.blog
/* Container for navigation on the left */
#project_nav
+media-lg
width: $project_nav-width * 1.33
width: $project_nav-width-lg
+media-sm
width: $project_nav-width-sm
+media-xs
width: initial
width: $project_nav-width-xs
display: block
left: 0
@@ -150,86 +125,10 @@ body.blog
/* Header with name and node edit tools */
#project_nav-header,
#project_context-header
align-items: center
color: white
display: flex
position: relative
#project_context-header
align-items: center
color: $color-text-dark-secondary
display: flex
height: $project_header-height
min-height: $project_header-height
position: fixed
top: $project_header-height + 1
transition: box-shadow 250ms ease-in-out
right: 0
z-index: $z-index-base + 3
&.is-offset
box-shadow: 0 0 25px rgba(black, .2)
span#status-bar
text-align: left
#project_nav-header
left: 0
position: absolute
top: 0
width: 100%
z-index: $z-index-base + 3
/* Name of the project */
.project-title
+text-overflow-ellipsis
height: 100%
max-width: 100%
width: 100%
span#status-bar
position: absolute
left: 0
top: 10px
min-width: 33%
padding: 5px 15px
color: $color-text-dark
opacity: 0
z-index: 1
font-weight: 400
white-space: nowrap
transition: all 250ms ease-in-out
+media-xs
left: 0
min-width: 100%
i
margin-right: 5px
&.info
color: $color-info
&.error
color: $danger
&.warning
color: $color-warning
&.success
color: $color-success
&.default
color: $color-text-light
&.active
opacity: 1
span#project-edit-title
position: absolute
padding: 15px 20px
font:
size: 1.1em
weight: 400
white-space: nowrap
/* Edit Asset buttons */
.project-mode-view,
@@ -238,24 +137,16 @@ span#project-edit-title
display: none
ul.project-edit-tools
align-items: center
display: flex
list-style-type: none
margin: 0 0 0 auto
padding: 0
li:not(.disabled)
.btn
background-color: $white
+media-xs
width: 100%
margin: 0 auto
justify-content: space-around
&:hover
border-color: $primary
background-color: $white
color: $primary
li
a, button
padding: $dropdown-item-padding-y ($dropdown-item-padding-x / 2)
i
padding-right: 10px
&.button-save
&.field-error
a
@@ -283,10 +174,6 @@ ul.project-edit-tools
cursor: default
+pulse-75
&.button-add,
&.button-edit
min-width: 80px
&.featured
a
color: $color-warning
@@ -303,8 +190,6 @@ ul.project-edit-tools
&.dropdown
li
padding: 0
a
color: $body-color
display: block
@@ -336,81 +221,6 @@ ul.project-edit-tools
/* // Extra asset tools in dropdown */
/* // Edit Asset buttons */
#project-loading
color: $color-text-light-primary
margin-left: auto
display: inline-block
background-color: transparent
opacity: 0
transition: opacity 150ms ease-in-out
+position-center-translate
i
position: relative
top: 2px
&.active
background-color: rgba($color-background-nav-dark, .7)
opacity: 1
i
+spin
animation-duration: .5s
#project-header+#project_tree,
#project-header+#project_context-container
margin-top: $project_header-height
ul.project_nav-edit-list
list-style: none
padding: 0
margin: $project_header-height 0 0 0
li
background-color: $color-background
border-bottom: 1px solid $color-background-dark
color: $color-text-dark
position: relative
&:hover
cursor: pointer
background-color: $color-background-light
a
padding: 10px 15px
display: inline-block
width: 100%
text-decoration: none
color: $color-text-dark
i
padding-right: 15px
&.active
background-color: white
a
color: $color-primary-dark
.project_nav-toggle-btn
position: absolute
bottom: 0
width: 100%
padding: 10px
text-align: center
color: $color-text-light-hint
cursor: pointer
&:hover
color: $color-text-light
i
font-size: 1.3em
+media-xs
visibility: hidden
display: none
#project_context
position: relative
display: flex
@@ -419,13 +229,7 @@ ul.project_nav-edit-list
height: 100%
background-color: white
#project_context-header+#project_context
padding-top: $project_header-height
#node-container
background-color: white
flex: 1
/* For error messages (403) and other overlaid text*/
#node-overlay
z-index: $z-index-base + 2
@@ -456,16 +260,13 @@ ul.project_nav-edit-list
#project_tree
+media-xs
margin-top: 0
// margin-top: $project_header-height //so it's right below the project title.
overflow-y: auto // show vertical scrollbars when needed.
padding: 0 0 5px 0 // some padding on top/bottom of jstree.
padding: 5px 0 // some padding on top/bottom of jsTree.
position: relative
&.edit
margin-top: 0
/* Node Context */
=project-node-title
font-size: 1.5em
@@ -701,12 +502,11 @@ section.node-preview
align-items: center
background-color: black
color: $color-text-light-primary
display: flex
flex: 1
// display: flex
justify-content: center
max-height: 500px
min-height: 200px
overflow: hidden
// min-height: 200px
// overflow: hidden
iframe
width: 100%
@@ -727,6 +527,8 @@ section.node-preview
&.image
cursor: zoom-in
display: flex
overflow: hidden
&.video
background-color: black
@@ -781,12 +583,6 @@ section.node-preview
color: $color-warning
margin-right: 10px
&.group
align-items: center
display: flex
padding: 20px
position: relative
&.project
background-color: black
width: 100%
@@ -831,11 +627,6 @@ section.node-preview-forbidden
hr
opacity: .5
section.node-details-container
background-color: white
&.project
padding-bottom: 15px
/* Narrower details for about page (since it doesn't have navtree) */
body.about
@@ -847,8 +638,7 @@ body.about
.node-title
+project-node-title
section.node-details-container,
section.node-preview.group
section.node-details-container
position: relative
.node-details-header
@@ -861,46 +651,20 @@ section.node-preview.group
width: 100%
max-width: 100%
.node-details-meta-actions
margin-left: auto
.btn-browsetoggle
+button(lighten($color-background-nav, 20%), 3px)
width: 48px
text-align: center
padding: 2px 4px
i
font-size: 1.3em
padding: 0
margin: 0
.node-details-description
+node-details-description
padding-left: 20px
padding-right: 20px
.node-details-meta
background-color: $color-background-light
border-bottom: thin solid $color-background
display: flex
flex-direction: column
font-weight: lighter
padding: 10px 20px
> ul
align-items: center
display: flex
list-style-type: none
margin: 0
padding: 0
> li
align-items: baseline
color: $color-text-dark-secondary
display: flex
padding-left: 10px
margin-left: 10px
padding-left: 5px
margin-left: 5px
&:first-child
margin-left: 0
@@ -913,55 +677,31 @@ section.node-preview.group
color: $color-success
&.download
position: relative
button
+button($color-success, 3px)
.dropdown-toggle
padding-right: 0
i.icon-dropdown-menu
padding-left: 10px
/* Download dropdown options */
ul.dropdown-menu
width: auto
min-width: 240px
padding: 0
margin: 0
top: initial
left: initial
right: 0
bottom: 35px
border: thin solid rgba(darken($color-background, 5%), .3)
border-top-left-radius: 3px
border-top-right-radius: 3px
border-bottom-left-radius: 0
border-bottom-right-radius: 0
li
padding: 0
text-align: right
clear: both
display: flex
align-items: center
display: block
a
display: flex
padding: 10px 15px
width: 100%
&:hover
color: $color-primary
background-color: transparent
text-decoration: none
span
color: $primary
&.length
color: lighten($color-primary, 10%)
span
color: $body-color
&.length
float: left
margin-right: auto
color: $color-text-dark-hint
padding-right: 15px
&.format
@@ -998,34 +738,38 @@ section.node-preview.group
&.status
color: $color-text-dark
ul.blender-id-badges
padding-left: 0
list-style-type: none
display: flex
> li
margin: 0
padding: 0
> a
display: block
padding: 2px
img
// TODO(Pablo): fix this so that it keeps the aspect ratio okay when the original image isn't square.
max-width: 16px
max-height: 16px
.node-details-license
align-items: center
border-bottom: thin solid $color-background
color: $color-text-dark
display: flex
font-weight: lighter
padding: 10px 20px
color: $color-text-dark-secondary
&:hover
color: $color-text-dark
color: $primary
span.type
i
color: $color-text-dark-primary
font-size: 1.8rem
margin-left: -5px
padding-right: 10px
i
font-size: 1.2rem
margin-left: -15px
&:after
top: 2px
left: -22px
position: relative
&:before
top: 2px
position: relative
&:after
left: -22px
position: relative
top: 1px
span
text-transform: uppercase
section.node-details-container
&.storage
@@ -1196,9 +940,6 @@ a.learn-more
font-size: 1.6em
left: 7px
.ribbon
+ribbon
.info
width: 100%
height: 100%
@@ -1233,12 +974,6 @@ a.learn-more
text-transform: capitalize
section.node-children
&.group, &.storage
flex: 1
padding: 10px 0 25px 20px
+clearfix
.list-node-children-container
position: relative
width: $list-node-children-item-width
@@ -1825,9 +1560,6 @@ section.node-children
padding: 20px
.form-group
position: relative
margin: 0 auto 30px auto
&.tags .select2-container
.select2-selection
+input-generic
@@ -2291,3 +2023,6 @@ section.node-children
margin: 25px 0 0 0
padding: 5px 35px
text-align: center
.ribbon
+ribbon

View File

@@ -16,7 +16,7 @@ $search-hit-width_grid: 100px
.search-hit-name
font-weight: 400
padding-top: 8px
color: $color-primary-dark
color: $primary
.search-hit
padding: 0
@@ -35,7 +35,7 @@ $search-hit-width_grid: 100px
+clearfix
& em
color: $color-primary-dark
color: $primary
font-style: normal
&:hover
@@ -70,7 +70,7 @@ $search-hit-width_grid: 100px
min-width: 350px
border-bottom-left-radius: 3px
border-bottom-right-radius: 3px
border-top: 3px solid lighten($color-primary, 5%)
border-top: 3px solid lighten($primary, 5%)
overflow: hidden
.tt-suggestion
@@ -92,235 +92,51 @@ $search-hit-width_grid: 100px
&.tt-cursor:hover .search-hit
background-color: lighten($color-background, 5%)
#search-container
display: flex
min-height: 600px
background-color: white
.search-list
width: 30%
+media-lg
padding-left: 0
padding-right: 0
.card-deck.card-deck-vertical
.card .embed-responsive
max-width: 80px
#search-sidebar
width: 20%
background-color: $color-background-light
input.search-field
border: none
border-bottom: 2px solid rgba($primary, .2)
border-radius: 0
width: 100%
transition: border 100ms ease-in-out
+media-lg
border-top-left-radius: 3px
&::placeholder
color: $color-text-dark-secondary
&:placeholder-shown
border-bottom-color: $primary
input.search-field
background-color: $color-background-nav-dark
font-size: 1.1em
color: white
margin-bottom: 10px
&:focus
outline: none
border: none
border-bottom: 2px solid rgba($color-primary, .2)
border-radius: 0
width: 100%
padding: 5px 15px
height: 50px
transition: border 100ms ease-in-out
border-bottom: 2px solid lighten($primary, 5%)
&::placeholder
color: $color-text-dark-secondary
&:placeholder-shown
border-bottom-color: $color-primary
.search-details
width: 70%
&:focus
outline: none
border: none
border-bottom: 2px solid lighten($color-primary, 5%)
.search-list-filters
padding:
left: 10px
right: 10px
.panel.panel-default
margin-bottom: 10px
border-radius: 3px
border: none
background-color: white
box-shadow: 1px 1px 0 rgba(black, .1)
a
text-decoration: none
.toggleRefine
display: block
padding-left: 7px
color: $color-text-dark
text-transform: capitalize
&:hover
text-decoration: none
color: $color-primary
&.refined
color: $color-primary
&:hover
color: $color-danger
span
&:before
/* x icon */
content: '\e84b'
font-family: 'pillar-font'
span
&:before
/* circle with dot */
content: '\e82f'
font-family: 'pillar-font'
position: relative
left: -7px
font-size: .9em
span
&:before
/* empty circle */
content: '\e82c'
font-family: 'pillar-font'
position: relative
left: -7px
font-size: .9em
.facet_count
color: $color-text-dark-secondary
.panel-title, .panel-heading
color: $color-text-dark-secondary
font:
size: 1em
weight: 500
.panel-body
padding-top: 0
.panel-title
position: relative
&:after
content: '\e83b'
font-family: 'pillar-font'
position: absolute
right: 0
color: $color-text-dark-primary
.collapsed
.panel-title:after
content: '\e838'
.search-list-stats
color: $color-text-dark-hint
padding: 10px 15px 0 15px
text-align: center
font-size: .9em
+clearfix
#pagination
ul.search-pagination
text-align: center
list-style-type: none
margin: 0
padding: 0
width: 100%
display: flex
+clearfix
li
display: inline-block
margin: 5px auto
&:last-child
border-color: transparent
a
font-weight: 500
padding: 5px 4px
color: $color-text-dark-secondary
&:hover
color: $color-text-dark-primary
&.disabled
opacity: .6
&.active a
color: $color-text-dark-primary
font-weight: bold
#search-list
width: 40%
height: 100%
padding: 0
position: relative
overflow-x: hidden
#search-details
position: relative
#search-hit-container
position: absolute // for scrollbars
overflow-y: auto
#hits
#error_container
position: relative
width: 100%
background: white
padding: 20px
#no-hits
padding: 10px 15px
color: $color-text-dark-secondary
.search-hit
#search-loading
visibility: hidden
background-color: transparent
font:
size: 1.5em
weight: 600
position: absolute
top: 0
left: 0
right: 0
bottom: 0
z-index: $z-index-base + 5
opacity: 0
cursor: default
transition: opacity 50ms ease-in-out
&.active
visibility: visible
opacity: 1
.spinner
color: $color-background-nav
background-color: white
padding: 0
width: 20px
height: 20px
border-radius: 50%
position: absolute
top: 7px
right: 10px
span
padding: 5px
+pulse
#search-details
position: relative
width: 40%
border-left: 2px solid darken(white, 3%)
#search-hit-container
position: absolute // for scrollbars
width: 100%
overflow-y: auto
#error_container
position: relative
background: white
padding: 20px
#search-error
display: none
margin: 20px auto
color: $color-danger
text-align: center
#search-error
display: none
margin: 20px auto
color: $color-danger
text-align: center
#search-container
#node-container
width: 100%
max-width: 100%
@@ -473,215 +289,118 @@ $search-hit-width_grid: 100px
button
width: 100%
.search-hit
float: left
box-shadow: none
border: thin solid transparent
border-top-color: darken(white, 8%)
border-left: 3px solid transparent
#project_sidebar+#search-sidebar,
#project_sidebar+#search-sidebar+#search-container
padding-left: $sidebar-width
color: $color-background-nav
.search-project
li.project
display: none
width: 100%
position: relative
margin: 0
padding: 7px 10px 7px 10px
+clearfix
&:first-child
border: thin solid transparent
border-left: 3px solid transparent
&:hover
opacity: 1
text-decoration: none
cursor: default
color: darken($color-primary, 20%)
background-color: $color-background-light
& .search-hit-name i
color: darken($color-primary, 20%)
& .search-hit-thumbnail
& .search-hit-thumbnail-icon
transform: translate(-50%, -50%) scale(1.1)
.search-hit-name
text-decoration: none
&:hover
color: darken($color-primary, 10%)
.search-hit-thumbnail
cursor: pointer
.search-hit-thumbnail-icon
transform: translate(-50%, -50%) scale(1)
&:active
background-color: rgba($color-background, .5)
opacity: .8
color: $color-primary
& .search-hit-name i
color: $color-primary
&:focus
border-color: rgba($color-primary, .2)
/* Class that gets added when we click on the item */
&.active
background-color: lighten($color-background, 2%)
border-left: 3px solid $color-primary
.search-hit-name
color: darken($color-primary, 10%)
.search-hit-meta
span.when
display: none
span.context
display: inline-block
.search-hit-thumbnail
position: relative
float: left
min-width: $search-hit-width_list * 1.49
max-width: $search-hit-width_list * 1.49
height: $search-hit-width_list
#search-sidebar
.card
margin-bottom: 10px
border-radius: 3px
background: $color-background
margin-right: 12px
text-align: center
overflow: hidden
+media-xs
display: none
+media-sm
min-width: $search-hit-width_list
max-width: $search-hit-width_list
border: none
background-color: white
box-shadow: 1px 1px 0 rgba(black, .1)
img
height: $search-hit-width_list
width: auto
a
text-decoration: none
.pi-video:before, .pi-file:before,
.pi-group:before
font-family: 'pillar-font'
.pi-video:before
content: '\e81d'
.pi-file:before
content: '\e825'
.pi-group:before
content: '\e80d'
.search-hit-thumbnail-icon
position: absolute
top: 50%
left: 50%
transform: translate(-50%, -50%)
color: white
font-size: 1.2em
transition: none
color: $color-text-dark-secondary
.dark
text-shadow: none
font-size: 1.3em
.search-hit-name
position: relative
font-size: 1.1em
color: $color-text-dark-primary
background-color: initial
width: initial
max-width: initial
+text-overflow-ellipsis
padding-top: 5px
.toggleRefine
display: block
padding-left: 7px
color: $color-text-dark
text-transform: capitalize
&:hover
cursor: pointer
text-decoration: underline
text-decoration: none
color: $primary
em
color: darken($color-primary, 15%)
font-style: normal
&.refined
color: $primary
.search-hit-ribbon
+ribbon
right: -30px
top: 5px
&:hover
color: $color-danger
span
&:before
/* x icon */
content: '\e84b'
font-family: 'pillar-font'
span
&:before
/* circle with dot */
content: '\e82f'
font-family: 'pillar-font'
position: relative
left: -7px
font-size: .9em
span
font-size: 60%
margin: 1px 0
padding: 2px 35px
.search-hit-meta
position: relative
font-size: .9em
&:before
/* empty circle */
content: '\e82c'
font-family: 'pillar-font'
position: relative
left: -7px
font-size: .9em
.facet_count
color: $color-text-dark-secondary
background-color: initial
padding: 3px 0 0 0
text-decoration: none
+text-overflow-ellipsis
span
&.project
color: $color-text-dark-secondary
margin-right: 3px
&.updated
color: $color-text-dark-hint
&.status
font-size: .8em
color: $color-text-dark-secondary
border: thin solid $color-text-dark-hint
padding: 3px 8px
text-transform: uppercase
border-radius: 3px
margin-right: 5px
&.media, &.node_type
color: $color-text-dark-secondary
text-transform: capitalize
margin: 0 3px
.card-title
position: relative
&:after
content: '\e83b'
font-family: 'pillar-font'
position: absolute
right: 0
color: $color-text-dark-primary
&.when
margin: 0 3px
float: right
display: block
+media-lg
display: block
+media-md
display: block
+media-sm
display: none
+media-xs
display: none
.collapsed
.card-title:after
content: '\e838'
&.context
margin: 0
float: right
display: none
&:hover
cursor: pointer
.search-hit-name-user
color: $color-primary
.search-list-stats
color: $color-text-dark-hint
padding: 10px 15px 0 15px
text-align: center
font-size: .9em
+clearfix
&.users
em
font-style: normal
color: $color-primary
.search-pagination
text-align: center
list-style-type: none
margin: 0
padding: 0
width: 100%
display: flex
+clearfix
.search-hit-name
font-size: 1.2em
li
display: inline-block
margin: 5px auto
small
margin-left: 5px
color: $color-text-dark-secondary
&:last-child
border-color: transparent
.search-hit-roles
font-size: .9em
a
font-weight: 500
padding: 5px 4px
color: $color-text-dark-secondary
margin-left: 15px
&:hover
color: $color-text-dark-primary
&.disabled
opacity: .6
&.active a
color: $color-text-dark-primary
font-weight: bold
.view-grid
display: flex
@@ -703,13 +422,13 @@ $search-hit-width_grid: 100px
transition: border-color 150ms ease-in-out
&.active
background-color: $color-primary
border-color: $color-primary
background-color: $primary
border-color: $primary
.search-hit-name
font-weight: 500
color: white
background-color: $color-primary
background-color: $primary
.search-hit-name
font-size: .9em
@@ -773,5 +492,5 @@ $search-hit-width_grid: 100px
&.active
color: white
background-color: $color-primary
background-color: $primary
border-color: transparent

View File

@@ -67,131 +67,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

View File

@@ -507,28 +507,22 @@
=ribbon
background-color: $color-success
cursor: default
border: thin dashed rgba(white, .5)
color: white
pointer-events: none
font-size: 70%
overflow: hidden
white-space: nowrap
position: absolute
right: -40px
top: 10px
-webkit-transform: rotate(45deg)
-moz-transform: rotate(45deg)
-ms-transform: rotate(45deg)
-o-transform: rotate(45deg)
transform: rotate(45deg)
white-space: nowrap
span
border: thin dashed rgba(white, .5)
color: white
display: block
font-size: 70%
margin: 1px 0
padding: 3px 50px
text:
align: center
transform: uppercase
@mixin text-background($text-color, $background-color, $roundness, $padding)
border-radius: $roundness
@@ -642,9 +636,7 @@
#{$property}: $color-status-review
=sidebar-button-active
background-color: $color-background-nav
box-shadow: inset 2px 0 0 $color-primary
color: white
color: $primary
.flash-on
background-color: lighten($color-success, 50%) !important
@@ -667,6 +659,9 @@
.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.
@@ -674,3 +669,18 @@
img
// Just re-use Bootstrap's mixin here.
+img-fluid
.overflow-hidden
overflow: hidden
=text-gradient($color_from, $color_to)
background: linear-gradient(to right, $color_from, $color_to)
background-clip: text
-webkit-background-clip: text
-webkit-text-fill-color: transparent
=active-gradient
+text-gradient($primary-accent, $primary)
&:before
+text-gradient($primary-accent, $primary)

View File

@@ -15,77 +15,37 @@
@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 _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
@import _notifications
#blog_container
+media-xs
@@ -99,9 +59,6 @@
padding: 20px
.form-group
position: relative
margin: 0 auto 30px auto
input, textarea, select
+input-generic
@@ -525,29 +482,3 @@
.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

@@ -1,23 +1,117 @@
.card-deck
// Custom, as of bootstrap 4.1.3 there is no way to do this.
&.card-3-columns
.card
min-width: 30%
max-width: 30%
&.card-deck-responsive
@extend .row
.card
@extend .col-md-3
+media-xs
flex: 1 0 50%
max-width: 50%
+media-sm
flex: 1 0 33%
max-width: 33%
+media-md
flex: 1 0 25%
max-width: 25%
+media-lg
flex: 1 0 20%
max-width: 20%
&.card-deck-vertical
@extend .flex-column
flex-wrap: initial
.card
@extend .w-100
@extend .flex-row
flex: initial
max-width: 100%
.card-img-top
@extend .rounded-0
.embed-responsive
@extend .mr-2
max-width: 120px
.card-body
@extend .overflow-hidden
.card-padless
.card
border: none
@extend .border-0
.card-body
padding: 15px 0
@extend .px-0
.card-fade
img
opacity: .8
transition: opacity ease-in-out 150ms
.card-image-fade
&:hover
.card-img-top
opacity: .9
.card.asset
color: $color-text
&:hover
img
opacity: 1
text-decoration: none
&.free
overflow: hidden
&:after
+ribbon
content: 'FREE'
padding: 2px 50px
.card-body
position: relative // for placing the progress
.card-text
font-size: $font-size-xs
.card-img-top
background-color: $color-background
background-size: cover
background-position: center
$card-progress-height: 5px
.progress
height: $card-progress-height
position: absolute
top: -$card-progress-height
width: 100%
.card-img-top
&.card-icon
display: flex
align-items: center
justify-content: center
font-size: 2em
i
opacity: .2
/* Tiny label for cards. e.g. 'WATCHED' on videos. */
.card-label
background-color: rgba($black, .5)
border-radius: 3px
color: $white
display: block
font-size: $font-size-xxs
left: 5px
top: -27px // enough to be above the progress-bar
position: absolute
padding: 1px 5px
z-index: 1
.card
&.active
.card-title
color: $primary

View File

@@ -1,13 +1,44 @@
// Global, we want all menus to look like this.
.dropdown-menu
ul.dropdown-menu
box-shadow: $dropdown-box-shadow
top: 95% // So there is less gap between the dropdown and the item.
> li
&:first-child > a
padding-top: ($dropdown-item-padding-y * 1.5)
&:last-child > a
padding-bottom: ($dropdown-item-padding-y * 1.5)
> a
padding: $dropdown-item-padding-y
padding-top: $dropdown-item-padding-y
padding-bottom: $dropdown-item-padding-y
.dropdown-divider
margin: 0
.dropdown-item:last-child
border-bottom-left-radius: $border-radius
border-bottom-right-radius: $border-radius
// Open dropdown on mouse hover dropdowns in the navbar.
nav .dropdown:hover
ul.dropdown-menu
display: block
nav .dropdown.large:hover
.dropdown-menu
@extend .d-flex
.dropdown.large.show
@extend .d-flex
.dropdown-menu.show
@extend .d-flex
.dropdown-menu-tab
display: none
min-width: 100px
&.show // .dropdown-menu-tab.show
@extend .d-flex

View File

@@ -20,6 +20,6 @@
overflow-x: hidden
position: absolute
right: 0
top: 60px
top: 40px
width: 420px
z-index: 9999

View File

@@ -2,6 +2,7 @@
.jumbotron
background-size: cover
border-radius: 0
margin-bottom: 0
padding-top: 10em
padding-bottom: 10em

View File

@@ -1,14 +1,12 @@
// Navigation.
/* Top level navigation bar. */
.navbar
box-shadow: inset 0 -2px $color-background
.navbar,
nav.sidebar
.nav
border: none
color: $color-text-dark-secondary
padding: 0
z-index: $z-index-base + 5 /* Flowplayer seems to take up to 11, project container is 12 */
z-index: $z-index-base + 5
nav
margin-left: auto
@@ -20,52 +18,27 @@ 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
img.gravatar
border-radius: 999em
height: 32px
width: 32px
box-shadow: 1px 1px 0 rgba(black, .2)
height: 28px
position: relative
width: 28px
.special
width: 18px
height: 18px
border-radius: 999em
position: absolute
background-color: white
z-index: 2
display: inline-block
top: 10px
left: 38px
font-size: 1.2em
border-radius: 999em
box-shadow: 1px 1px 1px rgba(black, .2)
display: inline-block
font-size: 1.2em
height: 18px
left: 28px
position: absolute
top: 3px
width: 18px
z-index: 2
&.subscriber
background-color: $color-success
@@ -83,64 +56,149 @@ nav.sidebar
i
+position-center-translate
.dropdown
min-width: 60px // navbar avatar size
.dropdown
min-width: 50px // navbar avatar size
span.fa-stack
position: absolute
top: 50%
left: 50%
transform: translate(-50%, -50%)
.navbar-item
&:hover
box-shadow: none // Remove the blue underline usually on navbar, from dropdown items.
ul.dropdown-menu
li
a
white-space: nowrap
ul.dropdown-menu
li
a
white-space: nowrap
&:hover
box-shadow: none // removes underline
.subitem // e.g. "Not Sintel? Log out"
font-size: .8em
text-transform: initial
&.subitem // e.g. "Not Sintel? Log out"
font-size: .8em
padding-top: 0
text-transform: initial
i
width: 30px
i
width: 30px
&.subscription-status
a, a:hover
color: $white
&.subscription-status
&.none a
color: $color-danger
&.none
background-color: $color-danger
&.subscriber a
color: $color-success
&.subscriber
background-color: $color-success
&.demo a
color: $color-info
&.demo
background-color: $color-info
span.info
span.info
display: block
span.renew
display: block
span.renew
display: block
color: $color-text-dark-primary
font-size: .9em
font-size: .9em
// Secondary navigation for
.nav-link
@extend .d-flex
.nav-title
white-space: nowrap
.navbar-item
align-items: center
display: flex
user-select: none
color: inherit
+media-sm
padding-left: 10px
padding-right: 10px
&:hover, &:focus
color: $primary
background-color: transparent
box-shadow: inset 0 -3px 0 $primary
text-decoration: none
&:focus
box-shadow: inset 0 -3px 0 $primary
&.active
color: $primary
box-shadow: inset 0 -3px 0 $primary
/* Secondary navigation. */
$nav-secondary-bar-size: -2px
.nav-secondary
align-items: center
box-shadow: inset 0 -2px 0 0 $color-background
box-shadow: inset 0 $nav-secondary-bar-size 0 0 $color-background
.nav-link
color: $color-text
cursor: pointer
transition: box-shadow 150ms ease-in-out
transition: color 150ms ease-in-out
&:hover,
&.active
box-shadow: inset 0 -2px 0 0 $primary
&:after
background-color: transparent
bottom: 0
content: ''
height: 2px
position: absolute
right: 0
left: 0
width: 0
transition: width 150ms ease-in-out
.nav-link:hover,
.nav-link.active,
.nav-item.dropdown.show > .nav-link
// Blue bar on the bottom.
&:after
background-color: $primary-accent
background-image: linear-gradient(to right, $primary-accent 70%, $primary)
height: 2px
width: 100%
span
+active-gradient
i
color: $primary-accent
&.nav-secondary-vertical
align-items: flex-start
flex-direction: column
box-shadow: none // Last item on the list already has a box-shadow.
> li
width: 100% // span across the whole width.
// Blue bar on the side.
.nav-link
&:hover,
&.active
color: $primary
@extend .bg-white
&:after
background-image: linear-gradient($primary-accent 70%, $primary)
height: 100%
left: initial
top: 0
width: 3px
// Big navigation dropdown.
.nav-main
.nav-secondary
.nav-link
@extend .pr-5
box-shadow: none
&.nav-see-more
color: $primary
font-size: $font-size-xxs
i, span
+active-gradient
.navbar-overlay
+media-lg
@@ -160,13 +218,6 @@ nav.sidebar
background-color: $color-background-nav
text-shadow: none
.navbar-brand
color: inherit
padding-left: 4px
&:hover
color: $primary
nav.navbar
.navbar-collapse
> ul > li > .navbar-item

View File

@@ -3,8 +3,33 @@
/* Makes it possible to override the path before importing font-pillar.sass */
$pillar-font-path: "../font" !default
/* Font aliases */
.pi /* blank item with the right spacing */
/* Font properties. */
@font-face
font-family: 'pillar-font'
src: url('#{$pillar-font-path}/pillar-font.woff?54788822') format("woff"), url('#{$pillar-font-path}/pillar-font.woff2?54788822') format("woff2")
font-weight: normal
font-style: normal
[class^="pi-"]:before, [class*=" pi-"]:before
font-family: "pillar-font"
font-style: normal
font-weight: normal
speak: none
display: inline-block
text-decoration: inherit
width: 1em
margin-right: .2em
text-align: center
font-variant: normal
text-transform: none
line-height: 1em
margin-left: .2em
-webkit-font-smoothing: antialiased
-moz-osx-font-smoothing: grayscale
/* Icon aliases. */
/* Empty icons, multiple names for the same/unasigned icon, etc. */
.pi, .pi-blank
&:after
content: ''
font-family: "pillar-font"
@@ -14,12 +39,10 @@ $pillar-font-path: "../font" !default
display: inline-block
text-decoration: inherit
width: 1em
margin-right: .2em
text-align: center
font-variant: normal
text-transform: none
line-height: 1em
margin-left: .2em
-webkit-font-smoothing: antialiased
-moz-osx-font-smoothing: grayscale
position: relative
@@ -27,10 +50,31 @@ $pillar-font-path: "../font" !default
&:before
position: relative
.pi-svnman:before
content: '\f1c0'
/* Assets */
.pi-group
@extend .pi-folder
.pi-video
@extend .pi-film-thick
.pi-file
@extend .pi-file-archive
.pi-asset
@extend .pi-file-archive
.pi-group_texture
@extend .pi-folder-texture
.pi-post
@extend .pi-newspaper
.pi-page
@extend .pi-document
/* License */
.pi-license-cc-zero:before
content: '\e85a'
.pi-license-cc-sa:before
content: '\e858'
top: 1px
.pi-license-cc-nd:before
content: '\e859'
.pi-license-cc-nc:before
@@ -59,47 +103,17 @@ $pillar-font-path: "../font" !default
&:before
left: 27px
/*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
* Here begins the CSS code generated by fontello.com, converted to *
* Sass and replaced the path with our variable $pillar-font-path. *
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
* Here begins the CSS code generated by fontello.com by using *
* the config.json file in /pillar/web/static/assets/font *
* Just convert the icon classes from pillar-font.css to Sass *
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
* When adding icons, only add/overwrite icon classes e.g. .pi-bla *
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*/
@font-face
font-family: 'pillar-font'
src: url('#{$pillar-font-path}/pillar-font.woff?55726379') format("woff"), url('#{$pillar-font-path}/pillar-font.woff2?55726379') format("woff2")
font-weight: normal
font-style: normal
[class^="pi-"]:before, [class*=" pi-"]:before
font-family: "pillar-font"
font-style: normal
font-weight: normal
speak: none
display: inline-block
text-decoration: inherit
width: 1em
margin-right: .2em
text-align: center
/* opacity: .8;
/* For safety - reset parent styles, that can break glyph codes
font-variant: normal
text-transform: none
/* fix buttons height, for twitter bootstrap
line-height: 1em
/* Animation center compensation - margins should be symmetric
/* remove if not needed
margin-left: .2em
/* you can be more comfortable with increased icons size
/* font-size: 120%;
/* Font smoothing. That was taken from TWBS
-webkit-font-smoothing: antialiased
-moz-osx-font-smoothing: grayscale
/* Uncomment for 3D effect
/* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3);
.pi-collection-plus:before
content: '\e800'
@@ -495,6 +509,11 @@ $pillar-font-path: "../font" !default
/* ''
.pi-speed:before
content: '\e84f'
/* ''
.pi-attention:before
content: '\e850'
@@ -645,11 +664,6 @@ $pillar-font-path: "../font" !default
/* ''
.pi-users:before
content: '\e86e'
/* ''
.pi-flamenco:before
content: '\e86f'
@@ -670,6 +684,11 @@ $pillar-font-path: "../font" !default
/* ''
.pi-users:before
content: '\e873'
/* ''
.pi-pause:before
content: '\f00e'
@@ -705,6 +724,16 @@ $pillar-font-path: "../font" !default
/* ''
.pi-social-instagram:before
content: '\f16d'
/* ''
.pi-database:before
content: '\f1c0'
/* ''
.pi-newspaper:before
content: '\f1ea'

View File

@@ -33,11 +33,10 @@ $tree-color-highlight-background-text: $primary
&[data-node-type="page"],
&[data-node-type="blog"]
color: darken($tree-color-highlight, 5%)
font-weight: bold
.jstree-anchor
padding: 5px 8px 1px 8px
padding: 0 6px
&:after
top: 3px !important

View File

@@ -1,5 +1,7 @@
$videoplayer-controls-color: white
$videoplayer-background-color: $black
$videoplayer-background-color: darken($primary, 10%)
$videoplayer-progress-bar-height: .5em
.video-js
.vjs-big-play-button:before, .vjs-control:before, .vjs-modal-dialog
@@ -30,7 +32,6 @@ $videoplayer-background-color: $black
font-weight: normal
font-style: normal
.vjs-icon-play
font-family: VideoJS
font-weight: normal
@@ -285,7 +286,6 @@ $videoplayer-background-color: $black
line-height: 1
font-weight: normal
font-style: normal
font-family: Arial, Helvetica, sans-serif
-webkit-user-select: none
-moz-user-select: none
-ms-user-select: none
@@ -453,20 +453,22 @@ body.vjs-full-window
list-style: none
margin: 0
padding: 0.2em 0
line-height: 1.4em
font-size: 1.2em
line-height: 1.8em
font-size: 1.1em
text-align: center
text-transform: lowercase
&:focus, &:hover
outline: 0
background-color: #73859f
background-color: rgba(115, 133, 159, 0.5)
background-color: darken($primary, 20%)
&.vjs-selected
background-color: $videoplayer-controls-color
color: $videoplayer-background-color
&:focus, &:hover
background-color: $videoplayer-controls-color
color: $videoplayer-background-color
&.vjs-menu-title
text-align: center
text-transform: uppercase
@@ -486,12 +488,13 @@ body.vjs-full-window
height: 0em
margin-bottom: 1.5em
border-top-color: $videoplayer-background-color
.vjs-menu-content
background-color: $videoplayer-background-color
position: absolute
width: 100%
bottom: 1.5em
max-height: 15em
max-height: 25em
.vjs-workinghover .vjs-menu-button-popup:hover .vjs-menu, .vjs-menu-button-popup .vjs-menu.vjs-lock-showing
display: block
@@ -655,12 +658,12 @@ body.vjs-full-window
-moz-transition: all 0.2s
-o-transition: all 0.2s
transition: all 0.2s
height: 0.3em
height: $videoplayer-progress-bar-height
.vjs-play-progress
position: absolute
display: block
height: 0.3em
height: $videoplayer-progress-bar-height
margin: 0
padding: 0
width: 0
@@ -670,7 +673,7 @@ body.vjs-full-window
.vjs-load-progress
position: absolute
display: block
height: 0.3em
height: $videoplayer-progress-bar-height
margin: 0
padding: 0
width: 0
@@ -680,7 +683,7 @@ body.vjs-full-window
div
position: absolute
display: block
height: 0.3em
height: $videoplayer-progress-bar-height
margin: 0
padding: 0
width: 0
@@ -692,10 +695,11 @@ body.vjs-full-window
.vjs-play-progress
background-color: $videoplayer-controls-color
border-radius: 999em
&:before
position: absolute
top: -0.333333333333333em
top: -($videoplayer-progress-bar-height / 2) // halfway the height of the progress bar
right: -0.5em
&:after
@@ -712,8 +716,8 @@ body.vjs-full-window
z-index: 1
.vjs-time-tooltip
background-color: $videoplayer-background-color
color: $videoplayer-controls-color
background-color: $videoplayer-background-color
z-index: 1
&:after
@@ -735,9 +739,9 @@ body.vjs-full-window
.vjs-time-tooltip
background-color: $videoplayer-controls-color
border-radius: 3px
border-radius: $border-radius
color: $videoplayer-background-color
font-family: $font-body
font-family: $font-family-base
font-size: 1.2em
font-weight: bold
padding: 5px 8px
@@ -851,9 +855,9 @@ body.vjs-full-window
font-size: 0.9em
.vjs-slider-horizontal .vjs-volume-level
height: 0.3em
height: $videoplayer-progress-bar-height
&:before
top: -0.3em
top: -$videoplayer-progress-bar-height
right: -0.5em
.vjs-menu-button-popup
@@ -1022,14 +1026,15 @@ video::-webkit-media-text-track-display
.vjs-playback-rate
.vjs-playback-rate-value
font-size: 1.5em
font-size: 1.25em
line-height: 2
position: absolute
top: 0
top: 3px
left: 0
width: 100%
height: 100%
text-align: center
.vjs-menu
width: 4em
left: 0em
@@ -1041,7 +1046,6 @@ video::-webkit-media-text-track-display
&:before
color: $videoplayer-controls-color
content: 'X'
font-family: Arial, Helvetica, sans-serif
font-size: 4em
left: 0
line-height: 1

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

@@ -6,14 +6,15 @@
| {% if node_type_name == 'group' %}
| {% set node_type_name = 'folder' %}
| {% endif %}
li.dropdown-item(class="button-{{ node_type['name'] }}")
a.item_add_node(
li(class="button-{{ node_type['name'] }}")
a.dropdown-item(
class="item_add_node",
href="#",
title="{{ node_type['description'] }}",
data-node-type-name="{{ node_type['name'] }}",
data-toggle="tooltip",
data-placement="left")
i.pi(class="icon-{{ node_type['name'] }}")
i.pi(class="pi-{{ node_type['name'] }}")
| {% if node_type_name == 'group_texture' %}
| Texture Folder
| {% elif node_type_name == 'group_hdri' %}

View File

@@ -0,0 +1,50 @@
| {% macro asset_list_item(asset, current_user) %}
| {% set node_type = asset.properties.content_type if asset.properties.content_type else asset.node_type %}
a.card.asset.card-image-fade.pr-0.mx-0.mb-2(
class="js-item-open {% if asset.permissions.world %}free{% endif %}",
data-node_id="{{ asset._id }}",
title="{{ asset.name }}",
href='{{ url_for_node(node=asset) }}')
.embed-responsive.embed-responsive-16by9
| {% if asset.picture %}
.card-img-top.embed-responsive-item(style="background-image: url({{ asset.picture.thumbnail('m', api=api) }})")
| {% else %}
.card-img-top.card-icon.embed-responsive-item
i(class="pi-{{ node_type }}")
| {% endif %}
.card-body.py-2.d-flex.flex-column
.card-title.mb-1.font-weight-bold
| {{ asset.name }}
ul.card-text.list-unstyled.d-flex.text-black-50.mt-auto
li.pr-2 {{ node_type | undertitle }}
li {{ asset._created | pretty_date }}
| {% if asset.properties.content_type == 'video' %}
| {% set view_progress = current_user.nodes.view_progress %}
| {% if asset._id in view_progress %}
| {% set progress = current_user.nodes.view_progress[asset._id] %}
| {% set progress_in_percent = progress.progress_in_percent %}
| {% set progress_done = progress.done %}
| {% endif %}
| {% if progress %}
.progress.rounded-0
.progress-bar(
role="progressbar",
style="width: {{ progress_in_percent }}%;",
aria-valuenow="{{ progress_in_percent }}",
aria-valuemin="0",
aria-valuemax="100")
| {% if progress.done %}
.card-label WATCHED
| {% endif %}
| {% endif %} {# endif progress #}
| {% endif %} {# endif video #}
| {% endmacro %}

View File

@@ -29,32 +29,20 @@ html(lang="en")
meta(name="twitter:image", content="")
| {% endblock %}
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery-3.1.0.min.js')}}")
script(src="{{ url_for('static_pillar', filename='assets/js/tutti.min.js') }}")
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.typeahead-0.11.1.min.js')}}")
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/js.cookie-2.0.3.min.js')}}")
| {% if current_user.is_authenticated %}
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/clipboard.min.js')}}")
| {% endif %}
script.
!function(e){"use strict";e.loadCSS=function(t,n,o){var r,i=e.document,l=i.createElement("link");if(n)r=n;else{var d=(i.body||i.getElementsByTagName("head")[0]).childNodes;r=d[d.length-1]}var a=i.styleSheets;l.rel="stylesheet",l.href=t,l.media="only x",r.parentNode.insertBefore(l,n?r:r.nextSibling);var f=function(e){for(var t=l.href,n=a.length;n--;)if(a[n].href===t)return e();setTimeout(function(){f(e)})};return l.onloadcssdefined=f,f(function(){l.media=o||"all"}),l},"undefined"!=typeof module&&(module.exports=e.loadCSS)}(this);
loadCSS( "//fonts.googleapis.com/css?family=Roboto:300,400" );
script(src="{{ url_for('static_pillar', filename='assets/js/markdown.min.js') }}")
script(src="{{ url_for('static_pillar', filename='assets/js/tutti.min.js') }}")
link(href="{{ url_for('static', filename='assets/img/favicon.png') }}", rel="shortcut icon")
link(href="{{ url_for('static', filename='assets/img/apple-touch-icon-precomposed.png') }}", rel="icon apple-touch-icon-precomposed", sizes="192x192")
link(href="{{ url_for('static_pillar', filename='assets/css/vendor/bootstrap.min.css') }}", rel="stylesheet")
| {% block head %}{% endblock %}
| {% block css %}
link(href="{{ url_for('static_pillar', filename='assets/css/font-pillar.css') }}", rel="stylesheet")
link(href="{{ url_for('static_pillar', filename='assets/css/base.css') }}", rel="stylesheet")
| {% if title == 'blog' %}
link(href="{{ url_for('static_pillar', filename='assets/css/blog.css') }}", rel="stylesheet")
| {% else %}
@@ -83,8 +71,6 @@ html(lang="en")
| {% endblock footer %}
| {% endblock footer_container%}
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.bootstrap-3.3.7.min.js') }}")
| {% if current_user.is_authenticated %}
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.typewatch-3.0.0.min.js') }}")
script.

View File

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

View File

@@ -12,30 +12,11 @@ li.dropdown
ul.dropdown-menu.dropdown-menu-right
| {% if not current_user.has_role('protected') %}
| {% block menu_list %}
li
a.navbar-item(
href="{{ url_for('projects.home_project') }}"
title="Home")
| #[i.pi-home] Home
li
a.navbar-item(
href="{{ url_for('projects.index') }}"
title="My Projects")
| #[i.pi-star] My Projects
| {% if current_user.has_organizations() %}
li
a.navbar-item(
href="{{ url_for('pillar.web.organizations.index') }}"
title="My Organizations")
| #[i.pi-users] My Organizations
| {% endif %}
li
a.navbar-item(
href="{{ url_for('settings.profile') }}"
title="Settings")
a.navbar-item.px-2(
href="{{ url_for('settings.profile') }}"
title="Settings")
| #[i.pi-cog] Settings
| {% endblock menu_list %}
@@ -45,11 +26,11 @@ li.dropdown
li
a.navbar-item(
href="{{ url_for('users.logout') }}")
href="{{ url_for('users.logout') }}")
i.pi-log-out(title="Log Out")
| Log out
a.navbar-item.subitem(
href="{{ url_for('users.switch') }}")
a.navbar-item.subitem.pt-0(
href="{{ url_for('users.switch') }}")
i.pi-blank
| Not {{ current_user.full_name }}?

View File

@@ -0,0 +1,73 @@
// {#
// Header of landing pages. title or text can be skipped:
// +jumbotron("{{ page_title }}", null, "{{ page_header_image }}")
// Any extra attributes added (in a separate group) will be passed as is:
// +jumbotron("{{ page_title }}", null, "{{ page_header_image }}")(data-node-id='{{ node._id }}')
// #}
mixin jumbotron(title, text, image, url)
if url
a.jumbotron.jumbotron-overlay.text-white(
style='background-image: url(' + image + ');',
href=url)&attributes(attributes)
.container
.row
.col-md-9
if title
.display-4.text-uppercase.font-weight-bold
=title
if text
.lead
=text
else
.jumbotron.jumbotron-overlay.text-white(style='background-image: url(' + image + ');')&attributes(attributes)
.container
.row
.col-md-9
if title
.display-4.text-uppercase.font-weight-bold
=title
if text
.lead
=text
// {# Secondary navigation.
// e.g. Workshops, Courses. #}
mixin nav-secondary(title)
ul.nav.nav-secondary&attributes(attributes)
if title
li.nav-item
span.nav-title.nav-link.font-weight-bold.pointer-events-none= title
if block
block
else
p No items defined.
mixin nav-secondary-link()
li.nav-item
a.nav-link&attributes(attributes)
block
mixin card-deck()
.card-deck.card-padless.card-deck-responsive()&attributes(attributes)
if block
block
else
.p-3 No items.
// {#
// Passes all attributes to the card.
// You can do fun stuff in a loop even like:
// +card(data-url="{{ url_for('projects.view', project_url=project.url) }}", tabindex='{{ loop.index }}')
// #}
mixin card()
.card&attributes(attributes)
if block
block
else
p No card content defined.
mixin list-asset(name, url, image, type, date)
if block
block

View File

@@ -33,8 +33,8 @@ script(type="text/javascript").
} else if (node_type === 'group_hdri') {
node_type_str = 'HDRi Folder';
}
$('a', '.button-edit').html('<i class="pi-edit button-edit-icon"></i> Edit ' + node_type_str);
$('a', '.button-delete').html('<i class="pi-trash button-delete-icon"></i>Delete ' + node_type_str);
$('a', '.button-delete').html('<i class="pr-2 pi-trash button-delete-icon"></i>Delete ' + node_type_str);
{% if parent %}
ProjectUtils.setProjectAttributes({parentNodeId: '{{parent._id}}'});
@@ -114,7 +114,6 @@ script(type="text/javascript").
}
}
$(page_overlay).find('.nav-prev').click(function(e){
e.stopPropagation();
e.preventDefault();
@@ -133,10 +132,6 @@ script(type="text/javascript").
$(this).removeClass('active').hide().html();
});
if (typeof $().popover != 'undefined'){
$('#asset-license').popover();
}
{% endif %}
var $content_type = $(".js-type");

View File

@@ -23,7 +23,7 @@ section.node-preview.video
| {% block node_download %}
| {% if node.file_variations %}
button.btn.btn-outline-secondary.dropdown-toggle(
button.btn.btn-sm.btn-outline-primary.dropdown-toggle.px-3(
type="button",
data-toggle="dropdown",
aria-haspopup="true",
@@ -32,7 +32,7 @@ button.btn.btn-outline-secondary.dropdown-toggle(
| Download
i.pi-angle-down.icon-dropdown-menu
ul.dropdown-menu
ul.dropdown-menu.dropdown-menu-right
| {% for variation in node.file_variations %}
li
a(href="{{ variation.link }}",
@@ -52,25 +52,49 @@ script(type="text/javascript").
{% if node.video_sources %}
var videoPlayer = document.getElementById('videoplayer');
var options = {
controlBar: {
volumePanel: { inline: false }
}
},
playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 4]
};
videojs.registerPlugin('analytics', function() {
videojs(videoPlayer, options).ready(function() {
this.ga({
'eventLabel' : '{{ node._id }} - {{ node.name }}',
'eventCategory' : '{{ node.project }}',
'eventsToTrack' : ['start', 'error', 'percentsPlayed']
});
this.hotkeys({
enableVolumeScroll: false,
customKeys: {
KeyL: {
key: function(event) {
return (event.which === 76);
},
handler: function(player, options, event) {
videoPlayerToggleLoop(videoPlayer, videoPlayerLoopButton);
}
}
}
});
this.rememberVolumePlugin();
{% if current_user.is_authenticated %}
let fetch_progress_url = '{{ url_for("users_api.get_video_progress", video_id=node._id) }}';
let report_url = '{{ url_for("users_api.set_video_progress", video_id=node._id) }}';
this.progressPlugin({
'report_url': report_url,
'fetch_progress_url': fetch_progress_url,
});
{% endif %}
});
videojs(videoPlayer, options).ready(function() {
this.hotkeys();
});
// Generic utility to add-buttons to the player.
function addVideoPlayerButton(data) {
var controlBar,
@@ -89,6 +113,7 @@ script(type="text/javascript").
return newButton;
}
// Video loop stuff. TODO: Move it to video_plugins.js
var videoPlayerLoopButton = addVideoPlayerButton({
player: videoPlayer,
class: 'vjs-loop-button',
@@ -96,15 +121,18 @@ script(type="text/javascript").
title: 'Loop'
});
videoPlayerLoopButton.onclick = function() {
function videoPlayerToggleLoop(videoPlayer, videoPlayerLoopButton) {
if (videoPlayer.loop){
videoPlayer.loop = false;
$(this).removeClass('vjs-control-active');
$(videoPlayerLoopButton).removeClass('vjs-control-active');
} else {
videoPlayer.loop = true;
$(this).addClass('vjs-control-active');
$(videoPlayerLoopButton).addClass('vjs-control-active');
}
}
videoPlayerLoopButton.onclick = function() {
videoPlayerToggleLoop(videoPlayer, videoPlayerLoopButton);
};
{% endif %} // if node.video_sources

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-4
h2.text-uppercase.font-weight-bold
| 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 'projects/_macros.html' import render_secondary_navigation %}
| {% 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 %}
| {{ render_secondary_navigation(project, navigation_links, title) }}
| {% 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 %}
| {% if node %}
| {{ blogmacros.render_blog_post(node, project=project) }}
| {% else %}
| {{ blogmacros.render_blog_index(project, posts, can_create_blog_posts, api, more_posts_available, posts_meta, pages=pages) }}
| {% endif %}
| {% 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 %}

View File

@@ -10,6 +10,9 @@
.comment-content
.comment-body
p.comment-author {{ comment._user.full_name }}
//- TODO(Pablo): due to the broad styling done on the .comment-content class the
//- styling for the badges that I put in _project.sass isn't applied properly here.
| {{ comment._user.badges_html|safe }}
span {{comment.properties | markdowned('content') }}

View File

@@ -1,127 +1,41 @@
| {% from '_macros/_asset_list_item.html' import asset_list_item %}
include ../../../mixins/components
| {% block body %}
#node-container
section.node-details-container
.node-details-header
.node-title#node-title
| {{node.name}}
.node-details-meta-actions
.btn-browsetoggle(
title="Toggle between list/grid view",
data-toggle="tooltip",
data-placement="top")
i.pi-list
section.d-flex
h4.p-4 {{node.name}}
| {% if node.description %}
.node-details-description
section.node-details-description.px-4
| {{ node | markdowned('description') }}
| {% endif %}
section.node-children.group
section.container-fluid
| {% if children %}
| {% for child in children %}
| {# Browse type: List #}
a(
href="{{ url_for_node(node=child) }}",
data-node_id="{{ child._id }}",
class="item_icon list-node-children-item browse-list")
.list-node-children-item-thumbnail
.d-flex.justify-content-end.mb-2
button.btn.btn-sm.btn-outline-secondary(
class="js-btn-browsetoggle",
title="Toggle between list/grid view",
data-toggle="tooltip",
data-placement="top")
i.pi-list
| {% if child.picture %}
img(
src="{{ child.picture.thumbnail('t', api=api)}} ")
| {% else %}
.cloud-logo
i.pi-blender-cloud
| {% endif %}
| {% if child.permissions.world %}
.list-node-children-item-ribbon
span free
| {% endif %}
.list-node-children-item-thumbnail-icon
| {% if child.properties.content_type and child.properties.content_type == 'video' %}
i.pi-play
| {% elif child.properties.content_type and child.properties.content_type == 'image' %}
i.pi-image
| {% elif child.properties.content_type and child.properties.content_type == 'file' %}
i.pi-file-archive
| {% else %}
i.pi-folder
| {% endif %}
.list-node-children-item-name {{ child.name }}
.list-node-children-item-meta
| {% if child.properties.status != 'published' %}
span.status {{ child.properties.status }}
| {% endif %}
span.type
| {% if child.properties.content_type %}
| {{ child.properties.content_type | undertitle }} ·
| {% elif child.node_type == 'group' %}
| Folder ·
| {% else %}
| {{ child.node_type | undertitle }} ·
| {% endif %}
span(title="Created on {{ child._created }}") {{ child._created | pretty_date }}
| {# Browse type: Icon #}
a(href="{{ url_for_node(node=child) }}",
data-node_id="{{ child._id }}",
title="{{ child.name }}",
class="item_icon list-node-children-item browse-icon {% if child.picture %}has-picture{% endif %}")
.list-node-children-item-thumbnail
| {% if child.picture %}
img(
src="{{ child.picture.thumbnail('m', api=api)}} ")
| {% else %}
.cloud-logo
i.pi-blender-cloud
| {% endif %}
.list-node-children-item-thumbnail-icon
| {% if child.properties.content_type and child.properties.content_type == 'video' %}
i.pi-play
| {% elif child.properties.content_type and child.properties.content_type == 'image' %}
i.pi-image
| {% elif child.properties.content_type and child.properties.content_type == 'file' %}
i.pi-file-archive
| {% else %}
i.pi-folder
| {% endif %}
| {% if child.properties.status != 'published' %}
.list-node-children-item-status {{ child.properties.status }}
| {% endif %}
| {% if child.permissions.world %}
.list-node-children-item-ribbon
span free
| {% endif %}
.list-node-children-item-name
span {{ child.name }}
| {% endfor %}
| {% else %}
.list-node-children-container
.list-node-children-empty No items... yet!
+card-deck(class="px-2")
| {% for child in children %}
| {{ asset_list_item(child, current_user) }}
| {% endfor %}
| {% else %}
.list-node-children-container
.list-node-children-empty No items... yet!
| {% endif %}
script.
// Generate GA pageview
ga('send', 'pageview', location.pathname);
$('a.item_icon').unbind("click")
$('a.js-item-open').unbind("click")
.click(function(e){
e.preventDefault();
@@ -149,15 +63,13 @@
// Browse type: icon or list
function projectBrowseTypeIcon() {
$(".list-node-children-item.browse-list").hide();
$(".list-node-children-item.browse-icon").show();
$(".btn-browsetoggle").html('<i class="pi-list"></i>');
$(".card-deck").removeClass('card-deck-vertical');
$(".js-btn-browsetoggle").html('<i class="pi-list"></i> List View');
};
function projectBrowseTypeList() {
$(".list-node-children-item.browse-list").show();
$(".list-node-children-item.browse-icon").hide();
$(".btn-browsetoggle").html('<i class="pi-layout"></i>');
$(".card-deck").addClass('card-deck-vertical');
$(".js-btn-browsetoggle").html('<i class="pi-layout"></i> Grid View');
};
function projectBrowseTypeCheck(){
@@ -197,7 +109,7 @@
}
}
$('.btn-browsetoggle').on('click', function (e) {
$('.js-btn-browsetoggle').on('click', function (e) {
e.preventDefault();
projectBrowseToggle();
});

View File

@@ -5,25 +5,22 @@
header
img.header(src="{{ node.picture.thumbnail('h', api=api) }}")
| {% endif %}
| {% block navbar_secondary %}
| {{ super() }}
| {% endblock navbar_secondary %}
#node-container
#node-overlay
section.node-details-container.page
.node-details-container.page.expand-image-links.imgs-fluid
.node-details-header
.node-title#node-title
| {{node.name}}
h2.pt-3.text-center {{node.name}}
hr
| {% if node.description %}
.node-details-description#node-description
| {{ node | markdowned('description') }}
| {{ node | markdowned('description') }}
| {% endif %}
.node-details-meta.footer
span.updated(title="created {{ node._created | pretty_date }}") updated {{ node._updated | pretty_date }}
small.text-muted
span(title="created {{ node._created | pretty_date }}") Updated {{ node._updated | pretty_date }}
include ../_scripts

View File

@@ -1,73 +0,0 @@
| {% extends 'projects/view.html' %}
| {% set title = 'blog' %}
| {% block og %}
meta(property="og:title", content="{{ node.name }}")
meta(property="og:url", content="{{ url_for('main.project_blog', project_url=project.url, url=node.properties.url, _external=True)}}")
meta(property="og:type", content="website")
| {% if node.picture %}
meta(property="og:image", content="{{ node.picture.thumbnail('l', api=api) }}")
| {% endif %}
meta(property="og:description", content="Blender Cloud is a web based service developed by Blender Institute that allows people to access the training videos and all the data from the open projects.")
meta(name="twitter:title", content="{{ node.name }}")
meta(name="twitter:description", content="Blender Cloud is a web based service developed by Blender Institute that allows people to access the training videos and all the data from the open projects.")
| {% if node.picture %}
meta(property="og:image", content="{{ node.picture.thumbnail('l', api=api) }}")
| {% endif %}
| {% endblock %}
| {% block page_title %}{{node.name}} - Blog{% endblock%}
| {% block css %}
| {{ super() }}
link(href="{{ url_for('static_pillar', filename='assets/css/blog.css') }}", rel="stylesheet")
| {% endblock %}
| {% block project_context %}
| {% include 'nodes/custom/post/view_embed.html' %}
| {% endblock %}
| {% block project_tree %}
#project_tree.jstree.jstree-default.blog
ul.jstree-container-ul.jstree-children
li.jstree-node(data-node-type="page")
a.jstree-anchor(
href="{{ url_for('projects.view', project_url=project.url) }}")
| Browse Project
li.jstree-node(data-node-type="page")
a.jstree-anchor(
href="{{ url_for('main.project_blog', project_url=project.url) }}") Blog
| {% for post in posts %}
li.jstree-node
a.jstree-anchor.tree-item.post(
href="{{ url_for_node(node=post) }}",
class="{% if post._id == node._id %}jstree-clicked{% endif %}")
.tree-item-thumbnail
| {% if post.picture %}
img(src="{{ post.picture.thumbnail('s', api=api) }}")
| {% else %}
i.pi-document-text
| {% endif %}
span.tree-item-title {{ post.name }}
span.tree-item-info {{ post._created | pretty_date }}
| {% endfor %}
| {% endblock %}
| {% block footer_scripts %}
script.
ProjectUtils.setProjectAttributes({projectId: "{{project._id}}", isProject: false, nodeId: '{{node._id}}'});
/* UI Stuff */
var project_container = document.getElementById('project-container');
$(window).on("load resize",function(){
containerResizeY($(window).height());
if ($(window).width() > 480) {
project_container.style.height = (window.innerHeight - project_container.offsetTop) + "px";
}
});
| {% endblock footer_scripts %}

View File

@@ -1,9 +0,0 @@
| {% import 'nodes/custom/blog/_macros.html' as blogmacros %}
| {{ blogmacros.render_blog_post(node, project=project) }}
#comments-embed.comments-compact
.comments-list-loading
i.pi-spin
include ../_scripts

View File

@@ -18,11 +18,6 @@ meta(property="og:image", content="{{ node.picture.thumbnail('l', api=api) }}")
| {% block page_title %}{{node.name}} - Blog{% endblock%}
| {% block css %}
| {{ super() }}
link(href="{{ url_for('static_cloud', filename='assets/css/project-landing.css') }}", rel="stylesheet")
| {% endblock css %}
| {% set title = 'blog' %}
| {% block body %}

View File

@@ -113,7 +113,6 @@ include ../_scripts
| {% block footer_scripts %}
script.
$('#asset-license').popover();
// Generate GA pageview
ga('send', 'pageview', location.pathname);

View File

@@ -71,7 +71,7 @@
hr
ul.project-edit-tools.justify-content-end.h-auto
ul.project-edit-tools.disabled.d-flex.list-unstyled.p-2.mb-0.h-auto.justify-content-end
li.button-cancel
a#item_cancel.item-cancel.project-mode-edit.btn.btn-outline-secondary(
href="javascript:void(0);",

View File

@@ -10,7 +10,7 @@
| {{node.name}}
| {% if node.description %}
.node-details-description#node-description
.node-details-description
| {{node.description}}
| {% endif %}

View File

@@ -1,4 +1,7 @@
| {% extends 'layout.html' %}
| {% from '_macros/_asset_list_item.html' import asset_list_item %}
include ../mixins/components
| {% block page_title %}Search{% if project %} {{ project.name }}{% endif %}{% endblock %}
| {% block head %}
@@ -28,86 +31,59 @@ script.
document.body.dataset["projectId"] = "{{project._id}}";
| {% endif %}
#search-container
| {% if project %}
#project_sidebar.bg-white
ul.project-tabs.p-0
li.tabs-browse(
title="Browse",
data-toggle="tooltip",
data-placement="right")
a(href="{{url_for('projects.view', project_url=project.url, _external=True)}}")
i.pi-folder
| {% if project %}
#project_sidebar
ul.project-tabs
li.tabs-thumbnail(
title="About",
data-toggle="tooltip",
data-placement="left",
class="{% if title == 'about' %}active {% endif %}{% if project.picture_square %}image{% endif %}")
a(href="{{url_for('projects.view', project_url=project.url, _external=True)}}")
#project-loading
i.pi-spin
| {% if project.picture_square %}
img(src="{{ project.picture_square.thumbnail('b', api=api) }}")
| {% else %}
i.pi-home
| {% endif %}
li.tabs-browse(
title="Browse",
data-toggle="tooltip",
data-placement="left")
a(href="{{url_for('projects.view', project_url=project.url, _external=True)}}")
i.pi-folder
li.tabs-search.active(
title="Search",
data-toggle="tooltip",
data-placement="left")
a(href="{{url_for('projects.search', project_url=project.url, _external=True)}}")
i.pi-search
| {% endif %}
li.tabs-search.active(
title="Search",
data-toggle="tooltip",
data-placement="right")
a(href="{{url_for('projects.search', project_url=project.url, _external=True)}}")
i.pi-search
| {% endif %}
#search-sidebar
input.search-field(
type="text",
name="q",
id="q",
autocomplete="off",
spellcheck="false",
autocorrect="false",
placeholder="Search by Title, Type...")
.search-list-filters
.filter-list
| View as:
ul.filter-list
li.filter-list-type.grid(
title="Browse as grid",
data-list-type="grid")
i.pi-layout
li.filter-list-type.list(
title="Browse as list",
data-list-type="list")
i.pi-list
#search-container.d-flex(class="{% if project %}search-project{% endif %}")
.search-list
input.search-field.p-2.bg-white(
type="text",
name="q",
id="q",
autocomplete="off",
spellcheck="false",
autocorrect="false",
placeholder="Search by Title, Type...")
#accordion.panel-group.accordion(role="tablist", aria-multiselectable="true")
#facets
#pagination.mt-3
#pagination
//- #accordion.panel-group.accordion(role="tablist", aria-multiselectable="true")
#facets
.search-list-stats
#stats
#stats.search-list-stats
#search-list
#hits
+card-deck()(id='hits', class="h-100 m-0 pt-3 pr-2 card-deck-vertical")
#search-details
#search-details.border-left.search-details
#search-error
#search-hit-container
#search-hit-container.w-100
| {% raw %}
// Facet template
script(type="text/template", id="facet-template")
.panel.panel-default
.card
a(data-toggle='collapse', data-parent='#accordion', href='#filter_{{ facet }}', aria-expanded='true', aria-controls='filter_{{ facet }}')
.panel-heading(role='tab')
.panel-title {{ title }}
.panel-collapse.collapse.in(id='filter_{{ facet }}', role='tabpanel', aria-labelledby='headingOne')
.panel-body
.card-header(role='tab')
.card-title {{ title }}
.collapse.show(id='filter_{{ facet }}', role='tabpanel', aria-labelledby='headingOne')
.card-body
| {{#values}}
a.facet_link.toggleRefine(
class='{{#refined}}refined{{/refined}}',
@@ -122,38 +98,33 @@ script(type="text/template", id="facet-template")
// Hit template
script(type="text/template", id="hit-template")
.search-hit(data-hit-id='{{ objectID }}')
#search-loading.search-loading
.spinner
span.spin ·
.search-hit-thumbnail
a.card.asset.card-image-fade.pl-0.mx-0.mb-1(
data-hit-id='{{ objectID }}',
href="/nodes/{{ objectID }}/redir",
class="js-search-hit {{#is_free}}free{{/is_free}}")
.embed-responsive.embed-responsive-16by9
| {{#picture}}
img(src="{{{ picture }}}")
.card-img-top.embed-responsive-item(style="background-image: url({{{ picture }}})")
| {{/picture}}
| {{^picture}}
.search-hit-thumbnail-icon
.card-img-top.card-icon.embed-responsive-item
| {{#media}}
i(class="pi-{{{ media }}}")
| {{/media}}
| {{^media}}
i.dark(class="pi-{{{ node_type }}}")
i(class="pi-{{{ node_type }}}")
| {{/media}}
| {{/picture}}
| {{#is_free}}
.search-hit-ribbon
span free
| {{/is_free}}
.search-hit-name
| {{ name }}
.search-hit-meta
span.project {{ project.name }}
span.node_type {{{ node_type }}}
| {{#media}}
span.media · {{{ media }}}
| {{/media}}
span.when {{{ created_at }}}
span.context
a(href="/nodes/{{ objectID }}/redir") view in context
.card-body.py-2.d-flex.flex-column
.card-title.mb-1.font-weight-bold
| {{ name }}
ul.card-text.list-unstyled.d-flex.text-black-50.mt-auto
li.pr-2.project {{ project.name }}
| {{#media}}
li.pr-2.text-capitalize {{{ media }}}
| {{/media}}
li.pr-2 {{{ created_at }}}
// Pagination template
@@ -174,7 +145,6 @@ script(type="text/template", id="stats-template")
| {% endblock %}
| {% block footer_scripts %}
script(src="//releases.flowplayer.org/6.0.5/flowplayer.min.js", async)
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/hogan.common-3.0.0.js') }}")
script(src="{{ url_for('static_pillar', filename='assets/js/elasticsearch.min.js') }}")
@@ -186,31 +156,30 @@ script.
$('#search-hit-container').html(dataHtml);
})
.done(function(){
$('.search-loading').removeClass('active');
$('.loader-bar').removeClass('active');
$('#search-error').hide();
$('#search-hit-container').show();
})
.fail(function(data){
$('.search-loading').removeClass('active');
$('.loader-bar').removeClass('active');
$('#search-hit-container').hide();
$('#search-error').show().html('Houston!\n\n' + data.status + ' ' + data.statusText);
});
}
$('body').on('click', '.search-hit', function(){
if ($('.search-loading').hasClass('active')){
$(this).removeClass('active');
}
$(this).find('#search-loading').addClass('active');
$('body').on('click', '.js-search-hit', function(e){
e.preventDefault();
$('.loader-bar').removeClass('active').addClass('active');
displayNode($(this).data('hit-id'));
$('.search-hit').removeClass('active');
$('.js-search-hit').removeClass('active');
$(this).addClass('active');
});
// Remove focus from search input so that the click event bound to .search-hit
// can be fired on the first click.
$(searchList).hover(function(){
// Remove focus from search input so that the click event
// bound to .search-hit can be fired on the first click.
$('#search-list').hover(function(){
$('#q').blur();
});
$('#search-sidebar').hover(function(){
@@ -218,42 +187,6 @@ script.
});
/* UI Stuff */
/* List types, grid or list (default)*/
var uiListType = Cookies.getJSON('bcloud_ui');
var searchList = document.getElementById('search-list');
function uiSetListType(type){
$('.filter-list-type').removeClass('active');
if (type == 'grid'){
$(searchList).addClass('view-grid');
$('.filter-list-type.grid').addClass('active');
} else {
$(searchList).removeClass('view-grid');
$('.filter-list-type.list').addClass('active');
}
}
if (uiListType && uiListType.search_browse_type == 'grid'){
uiSetListType('grid');
} else {
uiSetListType('list');
}
$('.filter-list-type').on('click', function(){
if ($(this).attr('data-list-type') == 'grid'){
uiSetListType('grid');
setJSONCookie('bcloud_ui', 'search_browse_type', 'grid');
} else {
uiSetListType('list');
setJSONCookie('bcloud_ui', 'search_browse_type', 'list');
}
});
/* Hide site-wide search, kinda confusing */
$('.search-input').hide();
/* Resize container so we can have custom scrollbars */
container_offset = $('#search-container').offset();

View File

@@ -5,9 +5,8 @@
| {% block node_preview %}
| {% if node.picture %}
| {% if current_user.has_cap('subscriber') or node.permissions.world %}
section#node-preview.node-preview.image.js-node-preview-image
img.node-preview-thumbnail#node-preview-thumbnail(
src="{{ node.picture.thumbnail('l', api=api) }}")
section.node-preview.image.js-node-preview-image
img.node-preview-thumbnail(src="{{ node.picture.thumbnail('l', api=api) }}")
| {% else %}
| {% include 'nodes/custom/_node_preview_forbidden.html' %}
| {% endif %}
@@ -15,110 +14,107 @@
| {% endblock node_preview %}
| {% block node_details %}
section.node-details-container
| {# NAME #}
.node-details-header
.node-title#node-title
| {{node.name}}
| {# NAME #}
section.px-4
h4.pt-4.mb-3 {{node.name}}
| {# DESCRIPTION #}
| {% if node.description %}
.node-details-description#node-description
.node-details-description
| {{ node | markdowned('description') }}
| {% endif %}
| {# LICENSE #}
| {% if node.properties.license_type %}
a.node-details-license(
href="https://creativecommons.org/licenses/",
target="_blank")
span.type
i(class="pi-license-{{ node.properties.license_type }}")
| License <span>{{ node.properties.license_type }}</span>
| {% if node.properties.license_notes %}
| &mdash; {{ node.properties.license_notes }}
| {% endif %}
| {% endif %}
| {# DETAILS #}
.node-details-meta
ul
| {% if node.has_method('PUT') and (node.properties.status != 'published') %}
li(class="status-{{ node.properties.status }}")
| {{ node.properties.status | undertitle }}
| {% endif %}
li(title="Author")
| {{ node.user.full_name }}
li(
title="Created {{ node._created }} (updated {{ node._updated | pretty_date_time }})")
| {{ node._created | pretty_date }}
| {% if node.short_link %}
li.shared
a(href="{{ node.short_link }}")
i.pi-share
| Shared
| {% endif %}
li.left-side
| {% if node.file %}
li(title="File size")
| {{ node.file.length | filesizeformat }}
li.js-type(title="File format")
| {{ node.file.content_type }}
| {% endif %}
| {% if node.permissions.world %}
li.public(
| {# DETAILS #}
section.node-details-meta.px-4.py-2
ul.list-unstyled.m-0
| {% if node.properties.license_type %}
li
a.node-details-license(
href="https://creativecommons.org/licenses/",
target="_blank",
title="{{ node.properties.license_type }} {% if node.properties.license_notes %}{{ node.properties.license_notes }}{% endif %}",
data-toggle="tooltip",
data-placement="bottom",
title="Anybody can download. Share it!")
i.pi-lock-open
span Public
data-placement="top")
i(class="pi-license-{{ node.properties.license_type }}")
| {% endif %}
| {% if node.has_method('PUT') and (node.properties.status != 'published') %}
li(class="status-{{ node.properties.status }}")
| {{ node.properties.status | undertitle }}
| {% endif %}
li(title="Author")
| {{ node.user.full_name }}
| {{ node.user.badges.html|safe }}
li(
title="Created {{ node._created }} (updated {{ node._updated | pretty_date_time }})")
| {{ node._created | pretty_date }}
| {% if node.short_link %}
li.shared
a(href="{{ node.short_link }}")
i.pi-share
| Shared
| {% endif %}
li.left-side
| {% if node.file %}
li(title="File size")
| {{ node.file.length | filesizeformat }}
li.js-type(title="File format")
| {{ node.file.content_type }}
| {% endif %}
| {% if node.permissions.world %}
li.public(
data-toggle="tooltip",
data-placement="bottom",
title="Anybody can download. Share it!")
i.pi-lock-open
span Public
| {% endif %}
| {% block node_details_meta_extra %}{% endblock %}
li.download
| {% if (current_user.has_cap('subscriber') or node.permissions.world) and (node.file or node.properties.files) %}
| {% block node_download %}
a(
title="Download {{ node.properties.content_type | undertitle }}",
href="{{ node.file.link }}",
download="{{ node.file.filename }}")
button.btn.btn-sm.btn-outline-primary.px-3(type="button")
i.pi-download.pr-2
| Download
| {% endblock node_download %}
| {% elif current_user.has_cap('can-renew-subscription') %}
a.btn.btn-success(
title="Renew your subscription to download",
target="_blank",
href="/renew")
i.pi-heart
| Renew Subscription
| {% elif current_user.is_authenticated %}
.btn.disabled
i.pi-lock
| Download
| {% else %}
a.btn(
title="Login to download {{ node.properties.content_type | undertitle }}",
href="{{ url_for('users.login') }}")
i.pi-lock
| Download
| {% endif %}
| {% block node_details_meta_extra %}{% endblock %}
li.download
| {% if (current_user.has_cap('subscriber') or node.permissions.world) and (node.file or node.properties.files) %}
| {% block node_download %}
a(
title="Download {{ node.properties.content_type | undertitle }}",
href="{{ node.file.link }}",
download="{{ node.file.filename }}")
button.btn(type="button")
i.pi-download
| Download
| {% endblock node_download %}
| {% elif current_user.has_cap('can-renew-subscription') %}
a.btn.btn-success(
title="Renew your subscription to download",
target="_blank",
href="/renew")
i.pi-heart
| Renew Subscription
| {% elif current_user.is_authenticated %}
.btn.disabled
i.pi-lock
| Download
| {% else %}
a.btn(
title="Login to download {{ node.properties.content_type | undertitle }}",
href="{{ url_for('users.login') }}")
i.pi-lock
| Download
| {% endif %}
| {% endblock node_details %}
| {% block node_comments %}

View File

@@ -1,41 +1,48 @@
| {% macro render_secondary_navigation(project, pages=None) %}
nav.navbar-secondary
nav.collapse.navbar-collapse
ul.navbar-nav.navbar-right
li
a.navbar-item(
href="{{ url_for('projects.view', project_url=project.url) }}",
title="{{ project.name }} Homepage")
span
b {{ project.name }}
li
a.navbar-item(
href="{{ url_for('main.project_blog', project_url=project.url) }}",
title="Project Blog",
class="{% if category == 'blog' %}active{% endif %}")
span Blog
| {% if pages %}
| {% for p in pages %}
li
a.navbar-item(
href="{{ url_for('projects.view_node', project_url=project.url, node_id=p._id) }}",
title="{{ p.name }}",
class="{% if category == 'page' %}active{% endif %}")
span {{ p.name }}
| {% endfor %}
| {% endif %}
| {% if project.nodes_featured %}
| {# In some cases featured_nodes might might be embedded #}
| {% if '_id' in project.nodes_featured[0] %}
| {% set featured_node_id=project.nodes_featured[0]._id %}
| {% else %}
| {% set featured_node_id=project.nodes_featured[0] %}
| {% endif %}
li
a.navbar-item(
href="{{ url_for('projects.view_node', project_url=project.url, node_id=featured_node_id) }}",
title="Explore {{ project.name }}",
class="{% if category == 'blog' %}active{% endif %}")
span Explore
| {% endif %}
include ../mixins/components
| {% macro render_secondary_navigation(project, navigation_links, title) %}
| {% if project.category == 'course' %}
| {% set category_url = url_for('cloud.courses') %}
| {% elif project.category == 'workshop' %}
| {% set category_url = url_for('cloud.workshops') %}
| {% elif project.category == 'film' %}
| {% set category_url = url_for('cloud.open_projects') %}
| {% else %}
| {% set category_url = url_for('main.homepage') %}
| {% endif %}
+nav-secondary()
| {% if project.url != 'blender-cloud' %}
li.text-capitalize
a.nav-link.text-muted.px-0(href="{{ category_url }}")
span {{ project.category }}
li.px-1
i.pi-angle-right
+nav-secondary-link(
class="px-1 font-weight-bold",
href="{{url_for('projects.view', project_url=project.url, _external=True)}}")
span {{ project.name }}
| {% endif %}
| {% if project.nodes_featured and (title !='project') %}
| {# In some cases featured_nodes might might be embedded #}
| {% if '_id' in project.nodes_featured[0] %}
| {% set featured_node_id=project.nodes_featured[0]._id %}
| {% else %}
| {% set featured_node_id=project.nodes_featured[0] %}
| {% endif %}
+nav-secondary-link(
href="{{ url_for('projects.view_node', project_url=project.url, node_id=featured_node_id) }}",
title="Explore {{ project.name }}",
class="{% if title == 'project' %}active{% endif %}")
span Explore
| {% endif %}
| {% for link in navigation_links %}
+nav-secondary-link(href="{{ link['url'] }}")
| {{ link['label'] }}
| {% endfor %}
| {% endmacro %}

View File

@@ -1,29 +1,36 @@
| {% extends 'projects/edit_layout.html' %}
| {% set title = 'edit' %}
| {% block page_title %}Edit {{ project.name }}{% endblock %}
| {% set title = 'edit' %}
| {% block project_context_header %}
span#project-edit-title
| Edit Project
#project_context-header.d-flex.justify-content-end.position-fixed.bg-white
ul.d-flex.list-unstyled.py-1.px-2.mb-0.h-auto.justify-content-end(
class="project-edit-tools disabled")
ul.project-edit-tools
// Edit Mode
li.button-cancel
a#item_cancel.project-mode-edit.btn.btn-sm.btn-link(
href="{{url_for('projects.view', project_url=project.url, _external=True)}}",
title="Cancel changes")
i.button-cancel-icon.pi-angle-left
| Go to Project
li.button-cancel
a#item_cancel.project-mode-edit.btn.btn-sm.btn-link(
href="{{url_for('projects.view', project_url=project.url, _external=True)}}",
title="Cancel changes")
i.button-cancel-icon.pi-angle-left
| Go to Project
li.button-save
a#item_save.project-mode-edit.btn.btn-sm.btn-outline-success.mx-2(
href="#",
title="Save changes")
i.button-save-icon.pi-check
| Save Changes
li.button-save
a.btn.btn-sm.btn-outline-success.ml-2.px-3.text-capitalize(
id="item_save",
class="project-mode-edit",
href="#",
title="Save changes")
i.pi-check.pr-2.button-save-icon
| Save Changes
| {% endblock %}
| {% block project_context %}
.container-fluid
.row
.col-md-12
h5.pl-2.mb-0 Project Overview
#node-edit-container
form(
id="node-edit-form"
@@ -81,7 +88,8 @@ ul.project-edit-tools
hr
ul.project-edit-tools.justify-content-end.h-auto
ul.d-flex.list-unstyled.py-1.px-2.mb-0.h-auto.justify-content-end(
class="project-edit-tools disabled")
li.button-cancel
a#item_cancel.project-mode-edit.btn.btn-link(
href="{{url_for('projects.view', project_url=project.url, _external=True)}}",
@@ -90,10 +98,12 @@ ul.project-edit-tools
| Go to Project
li.button-save
a#item_save.project-mode-edit.btn.btn-outline-success.ml-2(
a.btn.btn-sm.btn-outline-success.ml-2.px-3.text-capitalize(
id="item_save",
class="project-mode-edit",
href="#",
title="Save changes")
i.button-save-icon.pi-check
i.pi-check.pr-2.button-save-icon
| Save Changes
| {% endblock %}

View File

@@ -1,30 +1,28 @@
| {% extends 'layout.html' %}
include ../mixins/components
| {% block page_title %}Edit {{ project.name }}{% endblock %}
| {% block navigation_tabs %}
+nav-secondary()
+nav-secondary-link(
class="px-0 font-weight-bold",
href="{{url_for('projects.view', project_url=project.url, _external=True)}}")
| {{ project.name }}
| {% endblock navigation_tabs %}
| {% block body %}
#project-container
#project-side-container
#project_sidebar
ul.project-tabs.p-0
li.tabs-thumbnail(
title="About",
data-toggle="tooltip",
data-placement="left",
class="{% if title == 'about' %}active {% endif %}{% if project.picture_square %}image{% endif %}")
a(href="{{url_for('projects.view', project_url=project.url, _external=True)}}")
#project-loading
i.pi-spin
| {% if project.picture_square %}
img(src="{{ project.picture_square.thumbnail('b', api=api) }}")
| {% else %}
i.pi-home
| {% endif %}
li.tabs-browse(
title="Browse",
data-toggle="tooltip",
data-placement="left")
a(href="{{url_for('projects.view', project_url=project.url, _external=True)}}")
i.pi-folder
| {% if not project.is_private %}
li.tabs-search(
title="Search",
@@ -43,46 +41,42 @@
i.pi-cog
| {% endif %}
.project_nav-toggle-btn(
title="Expand Navigation [T]",
data-toggle="tooltip",
data-placement="right")
i.pi-angle-double-left
#project_nav
#project_nav-container
#project_nav-header.bg-white
a.project-title.p-2.font-weight-bold.text-dark(
href="{{url_for('projects.view', project_url=project.url, _external=True)}}")
| {{ project.name }}
// TODO - make list a macro
#project_tree.edit.bg-white
ul.project_nav-edit-list
li(class="{% if title == 'edit' %}active{% endif %}")
a(href="{{ url_for('projects.edit', project_url=project.url) }}")
i.pi-list
| Overview
li(class="{% if title == 'sharing' %}active{% endif %}")
a(href="{{ url_for('projects.sharing', project_url=project.url) }}")
i.pi-share
| Sharing
li(class="{% if title == 'edit_node_types' %}active{% endif %}")
a(href="{{ url_for('projects.edit_node_types', project_url=project.url) }}")
i.pi-puzzle
| Node Types
+nav-secondary()(class="nav-secondary-vertical")
+nav-secondary-link(
class="{% if title == 'edit' %}active{% endif %}",
href="{{ url_for('projects.edit', project_url=project.url) }}")
i.pr-3.pi-list
| Overview
+nav-secondary-link(
class="{% if title == 'sharing' %}active{% endif %}",
href="{{ url_for('projects.sharing', project_url=project.url) }}")
i.pr-3.pi-share
| Sharing
+nav-secondary-link(
class="{% if title == 'edit_node_types' %}active{% endif %}",
href="{{ url_for('projects.edit_node_types', project_url=project.url) }}")
i.pr-3.pi-puzzle
| Node Types
| {% for ext in ext_pages %}
li(class="{% if title == ext.name %}active{% endif %}")
a(href="{{ url_for('projects.edit_extension', project_url=project.url, extension_name=ext.name) }}")
i(class="pi-{{ext.icon}}")
| {{ext.name | title}}
+nav-secondary-link(
class="{% if title == ext.name %}active{% endif %}",
href="{{ url_for('projects.edit_extension', project_url=project.url, extension_name=ext.name) }}")
i(class="pr-3 pi-{{ ext.icon }}")
| {{ ext.name | title }}
| {% endfor %}
#project_context-container
#project_context-header.bg-white
span#status-bar
| {% block project_context_header %}
| {% endblock %}
#project_context-container.border-left
| {% block project_context_header %}
| {% endblock %}
#project_context
| {% block project_context %}

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