161 Commits

Author SHA1 Message Date
77e3c476f0 Move node hooks into own file 2018-09-16 13:04:12 +02:00
842ddaeab0 Assets: Display similar assets based on tags
Experimental.
2018-09-16 06:29:19 +02:00
85e5cb4f71 Projects: Only display category for public projects 2018-09-16 05:02:52 +02:00
6648f8d074 Minor style adjustments 2018-09-16 05:02:16 +02:00
a5bc36b1cf Jumbotron overlay is now optional.
Just add the jumbotron-overlay class, or jumbotron-overlay-gradient
2018-09-16 04:28:11 +02:00
e56b3ec61f Use Pillar's built-in markdown when editing projects/creating posts. 2018-09-16 04:27:24 +02:00
9624f6bd76 Style pages 2018-09-16 04:05:37 +02:00
4e5a53a19b Option to limit card-deck to a maximum N columns
Only 3 supported for now
2018-09-16 03:42:48 +02:00
fbc7c0fce7 CSS: media breakpoints
from Bootstrap and added a couple more for super big screens
2018-09-16 03:39:54 +02:00
bb483e72aa CSS cleanup (blog, comments) 2018-09-16 03:05:34 +02:00
baf27fa560 Blog: Fix and css cleanup 2018-09-16 02:04:14 +02:00
845ba953cb Make YouTube shortcode embeds responsive
Part of T56813
2018-09-15 22:32:03 +02:00
e5b7905a5c Project: Sort navigation links
See T56813
2018-09-15 22:12:12 +02:00
88c0ef0e7c Blog: fixes and tweaks 2018-09-15 21:32:54 +02:00
f8d992400e Extend attachment shortcode rendering
The previous implementation only supported rendering
attachments within the context of a node or project document.
Now it also supports node.properties. This is a temporary
solution, as noted in the TODO comments.
2018-09-15 19:01:58 +02:00
263d68071e Add view_progress to nodes of type asset 2018-09-15 17:59:30 +02:00
0f7f7d5a66 Profile styling, layout and cleanup. 2018-09-15 16:42:29 +02:00
6b29c70212 Navigation menu: Style see-more items 2018-09-15 06:16:06 +02:00
07670dce96 Fix view type list for folders 2018-09-15 05:50:42 +02:00
fe288b1cc2 Typo 2018-09-15 05:50:10 +02:00
2e9555e160 Layout and style for new global menu. 2018-09-15 05:41:15 +02:00
b0311af6b5 CSS: $primary-accent color and gradient utils 2018-09-15 05:40:29 +02:00
35a22cab4b Fix wrong url 2018-09-14 23:12:02 +02:00
0055633732 Blog: Styling and cleanup 2018-09-14 20:30:04 +02:00
78b186c8e4 Blog: Unify all post viewing in one template
During the years we went from site-wide blog, to project blog, to
post view inside a project, to full one-page post view. This led
to have multiple ways to see the same content.

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

Should be eventually moved to Blender Cloud repo.
2018-09-14 16:58:48 +02:00
32c7ffbc99 Move project-main to Blender Cloud
Also remove calls to project-landing, it is now part of project-main.
It was just a few lines of code not worth having a different CSS file.
2018-09-14 16:56:35 +02:00
cfcc629b61 Update package-lock.json 2018-09-14 13:11:49 +02:00
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
0baf5b38c3 Project view: dim title link 2018-09-06 12:52:54 +02:00
858a75af8d Pug: Move project home templates to blender-cloud
These are super hard-coded to the Cloud anyway.
2018-09-06 12:51:58 +02:00
6b1a5e24e8 Pug: Use templates from blender-cloud
Affects the following templates:

/projects/view.pug
/projects/index_dashboard.pug
/organizations/index.pug

A lot of this layout is hardcoded for blender-cloud anyway. Eventually
Pillar should have its own templates to use as starting point for building
other Pillar apps. This should be built using the minimal amount of code
possible and rely on styling possible via Bootstrap.
2018-09-06 12:46:33 +02:00
1500e20291 Blog: cleanup of layout and style
Simpler markup reusing bootstrap 4 classes.
2018-09-06 12:42:37 +02:00
d347534fea Pug: Move navigation macro to blender-cloud 2018-09-06 12:19:28 +02:00
4546469d37 Pug: Move blog macros to blender-cloud 2018-09-06 12:19:00 +02:00
b0d8da821f CSS: Blog cleanup 2018-09-06 12:11:18 +02:00
1821bb6b7d CSS general cleanup and minor style tweaks 2018-09-06 12:11:10 +02:00
278eebd235 Style jsTree 2018-09-06 12:06:14 +02:00
2777c37085 Style videoplayer. 2018-09-06 12:05:45 +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
9c2ded79dd CSS: Cleanup and simplification
Mainly to rely more on bootstrap styling
2018-08-31 19:32:17 +02:00
b4acfb89fa Layout: use bootstrap classes 2018-08-31 19:31:36 +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
33bd2c5880 Sass: Import modules on top level 2018-08-31 14:26:42 +02:00
76338b4568 Sass config: Bootstrap overrides 2018-08-31 14:24:25 +02:00
7405e198eb Use .displayAs() instead of .show()
Needed for CSS display to be set as inline-block instead of show()'s inline.
2018-08-31 14:23:23 +02:00
2332bc0960 jQuery: Small utility to set CSS display type
Showing elements with jQuery's native .show() sets display as 'inline',
but sometimes we need to set 'flex' or 'inline-block'.
2018-08-31 14:20:59 +02:00
ac3a599bb6 Gulp: build our own bootstrap js only using the needed modules.
At this point we only use tooltip and dropdown code, but we could use
tabs or carousels in the future. Just add them to the toUglify list.
2018-08-31 14:19:09 +02:00
814275fc95 Gulp: only chmod when running --production 2018-08-31 14:17:39 +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
46b0d6d663 Upgrade npm dependencies
Change gulp-uglify for gulp-uglify-es which has support for ES6.

New dependencies:
* boostrap
* jquery
* popper.js (required by bootstrap)
2018-08-29 16:30:17 +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
40f79af49d Tooltips: Cleanup 2018-08-28 15:54:14 +02:00
84608500b9 CSS: Split dropdown styling 2018-08-28 15:53:47 +02:00
819300f954 Navbar cleanup 2018-08-28 15:52:56 +02:00
b569829343 General cleanup 2018-08-28 15:52:50 +02:00
c35fb6202b render_secondary_navigation: Bootstrap 4 tweaks 2018-08-28 15:51:56 +02:00
d0ff519980 Font Pillar: Aliases for CC license icons
Also comments about updating the font from fontello.com
2018-08-27 17:03:13 +02:00
6ff4ee8fa1 Minor Dashboard style tweaks 2018-08-27 17:02:36 +02:00
b5535a8773 CSS: New primary color and navbar height 2018-08-27 17:02:07 +02:00
2ded541955 CSS Cleanup: remove font-body specifics 2018-08-27 17:01:43 +02:00
3965061bde CSS: Split into modules
Don't place pure styling on top-level files (those that don't begin with underscore).
Instead, import them as individual files.
2018-08-27 17:01:08 +02:00
5238e2c26d Pillar Font: Use variable for path 2018-08-22 19:57:22 +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
173 changed files with 7416 additions and 12760 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

@@ -12,15 +12,16 @@ var pug = require('gulp-pug');
var rename = require('gulp-rename');
var sass = require('gulp-sass');
var sourcemaps = require('gulp-sourcemaps');
var uglify = require('gulp-uglify');
var uglify = require('gulp-uglify-es').default;
var enabled = {
uglify: argv.production,
maps: argv.production,
maps: !argv.production,
failCheck: !argv.production,
prettyPug: !argv.production,
cachify: !argv.production,
cleanup: argv.production,
chmod: argv.production,
};
var destination = {
@@ -29,6 +30,11 @@ var destination = {
js: 'pillar/web/static/assets/js',
}
var source = {
bootstrap: 'node_modules/bootstrap/',
jquery: 'node_modules/jquery/',
popper: 'node_modules/popper.js/'
}
/* CSS */
gulp.task('styles', function() {
@@ -67,36 +73,50 @@ gulp.task('scripts', function() {
.pipe(gulpif(enabled.uglify, uglify()))
.pipe(rename({suffix: '.min'}))
.pipe(gulpif(enabled.maps, sourcemaps.write(".")))
.pipe(chmod(644))
.pipe(gulpif(enabled.chmod, chmod(644)))
.pipe(gulp.dest(destination.js))
.pipe(gulpif(argv.livereload, livereload()));
});
/* Collection of scripts in src/scripts/tutti/ to merge into tutti.min.js */
/* Since it's always loaded, it's only for functions that we want site-wide */
/* Collection of scripts in src/scripts/tutti/ to merge into tutti.min.js
* Since it's always loaded, it's only for functions that we want site-wide.
* It also includes jQuery and Bootstrap (and its dependency popper), since
* the site doesn't work without it anyway.*/
gulp.task('scripts_concat_tutti', function() {
gulp.src('src/scripts/tutti/**/*.js')
toUglify = [
source.jquery + 'dist/jquery.min.js',
source.popper + 'dist/umd/popper.min.js',
source.bootstrap + 'js/dist/index.js',
source.bootstrap + 'js/dist/util.js',
source.bootstrap + 'js/dist/tooltip.js',
source.bootstrap + 'js/dist/dropdown.js',
'src/scripts/tutti/**/*.js'
];
gulp.src(toUglify)
.pipe(gulpif(enabled.failCheck, plumber()))
.pipe(gulpif(enabled.maps, sourcemaps.init()))
.pipe(concat("tutti.min.js"))
.pipe(gulpif(enabled.uglify, uglify()))
.pipe(gulpif(enabled.maps, sourcemaps.write(".")))
.pipe(chmod(644))
.pipe(gulpif(enabled.chmod, chmod(644)))
.pipe(gulp.dest(destination.js))
.pipe(gulpif(argv.livereload, livereload()));
});
gulp.task('scripts_concat_markdown', function() {
gulp.src('src/scripts/markdown/**/*.js')
.pipe(gulpif(enabled.failCheck, plumber()))
.pipe(gulpif(enabled.maps, sourcemaps.init()))
.pipe(concat("markdown.min.js"))
.pipe(gulpif(enabled.uglify, uglify()))
.pipe(gulpif(enabled.maps, sourcemaps.write(".")))
.pipe(chmod(644))
.pipe(gulp.dest(destination.js))
.pipe(gulpif(argv.livereload, livereload()));
/* Simply move these vendor scripts from node_modules. */
gulp.task('scripts_move_vendor', function(done) {
let toMove = [
'node_modules/video.js/dist/video.min.js',
];
gulp.src(toMove)
.pipe(gulp.dest(destination.js + '/vendor/'));
done();
});
@@ -111,9 +131,9 @@ gulp.task('watch',function() {
gulp.watch('src/templates/**/*.pug',['templates']);
gulp.watch('src/scripts/*.js',['scripts']);
gulp.watch('src/scripts/tutti/**/*.js',['scripts_concat_tutti']);
gulp.watch('src/scripts/markdown/**/*.js',['scripts_concat_markdown']);
});
// Erases all generated files in output directories.
gulp.task('cleanup', function() {
var paths = [];
@@ -136,5 +156,5 @@ gulp.task('default', tasks.concat([
'templates',
'scripts',
'scripts_concat_tutti',
'scripts_concat_markdown',
'scripts_move_vendor',
]));

3597
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,32 @@
{
"name": "pillar",
"license": "GPL-2.0+",
"author": "Blender Institute",
"repository": {
"type": "git",
"url": "https://github.com/armadillica/pillar.git"
},
"devDependencies": {
"gulp": "~3.9.1",
"gulp-autoprefixer": "~2.3.1",
"gulp-cached": "~1.1.0",
"gulp-chmod": "~1.3.0",
"gulp-concat": "~2.6.0",
"gulp-if": "^2.0.1",
"gulp-git": "~2.4.2",
"gulp-livereload": "~3.8.1",
"gulp-plumber": "~1.1.0",
"gulp-pug": "~3.2.0",
"gulp-rename": "~1.2.2",
"gulp-sass": "~2.3.1",
"gulp-sourcemaps": "~1.6.0",
"gulp-uglify": "~1.5.3",
"minimist": "^1.2.0"
}
"name": "pillar",
"license": "GPL-2.0+",
"author": "Blender Institute",
"repository": {
"type": "git",
"url": "git://git.blender.org/pillar.git"
},
"devDependencies": {
"gulp": "^3.9.1",
"gulp-autoprefixer": "^6.0.0",
"gulp-cached": "^1.1.1",
"gulp-chmod": "^2.0.0",
"gulp-concat": "^2.6.1",
"gulp-if": "^2.0.2",
"gulp-git": "^2.8.0",
"gulp-livereload": "^4.0.0",
"gulp-plumber": "^1.2.0",
"gulp-pug": "^4.0.1",
"gulp-rename": "^1.4.0",
"gulp-sass": "^4.0.1",
"gulp-sourcemaps": "^2.6.4",
"gulp-uglify-es": "^1.0.4",
"minimist": "^1.2.0"
},
"dependencies": {
"bootstrap": "^4.1.3",
"jquery": "^3.3.1",
"popper.js": "^1.14.4",
"video.js": "^7.2.2"
}
}

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,16 +1,12 @@
import base64
import functools
import logging
import urllib.parse
import pymongo.errors
import werkzeug.exceptions as wz_exceptions
from bson import ObjectId
from flask import current_app, Blueprint, request
from pillar.api.activities import activity_subscribe, activity_object_add
from pillar.api.node_types import PILLAR_NAMED_NODE_TYPES
from pillar.api.file_storage_backends.gcs import update_file_name
from pillar.api.nodes import hooks
from pillar.api.nodes.hooks import short_link_info
from pillar.api.utils import str2id, jsonify
from pillar.api.utils.authorization import check_permissions, require_login
@@ -19,40 +15,6 @@ blueprint = Blueprint('nodes_api', __name__)
ROLES_FOR_SHARING = {'subscriber', 'demo'}
def only_for_node_type_decorator(*required_node_type_names):
"""Returns a decorator that checks its first argument's node type.
If the node type is not of the required node type, returns None,
otherwise calls the wrapped function.
>>> deco = only_for_node_type_decorator('comment')
>>> @deco
... def handle_comment(node): pass
>>> deco = only_for_node_type_decorator('comment', 'post')
>>> @deco
... def handle_comment_or_post(node): pass
"""
# Convert to a set for efficient 'x in required_node_type_names' queries.
required_node_type_names = set(required_node_type_names)
def only_for_node_type(wrapped):
@functools.wraps(wrapped)
def wrapper(node, *args, **kwargs):
if node.get('node_type') not in required_node_type_names:
return
return wrapped(node, *args, **kwargs)
return wrapper
only_for_node_type.__doc__ = "Decorator, immediately returns when " \
"the first argument is not of type %s." % required_node_type_names
return only_for_node_type
@blueprint.route('/<node_id>/share', methods=['GET', 'POST'])
@require_login(require_roles=ROLES_FOR_SHARING)
def share_node(node_id):
@@ -88,6 +50,67 @@ def share_node(node_id):
return jsonify(short_link_info(short_code), status=status)
@blueprint.route('/tagged/')
@blueprint.route('/tagged/<tag>')
def tagged(tag=''):
"""Return all tagged nodes of public projects as JSON."""
from pillar.auth import current_user
# We explicitly register the tagless endpoint to raise a 404, otherwise the PATCH
# handler on /api/nodes/<node_id> will return a 405 Method Not Allowed.
if not tag:
raise wz_exceptions.NotFound()
# Build the (cached) list of tagged nodes
agg_list = _tagged(tag)
# If the user is anonymous, no more information is needed and we return
if current_user.is_anonymous:
return jsonify(agg_list)
# If the user is authenticated, attach view_progress for video assets
view_progress = current_user.nodes['view_progress']
for node in agg_list:
node_id = str(node['_id'])
# View progress should be added only for nodes of type 'asset' and
# with content_type 'video', only if the video was already in the watched
# list for the current user.
if node_id in view_progress:
node['view_progress'] = view_progress[node_id]
return jsonify(agg_list)
def _tagged(tag: str):
"""Fetch all public nodes with the given tag.
This function is cached, see setup_app().
"""
nodes_coll = current_app.db('nodes')
agg = nodes_coll.aggregate([
{'$match': {'properties.tags': tag,
'_deleted': {'$ne': True}}},
# Only get nodes from public projects. This is done after matching the
# tagged nodes, because most likely nobody else will be able to tag
# nodes anyway.
{'$lookup': {
'from': 'projects',
'localField': 'project',
'foreignField': '_id',
'as': '_project',
}},
{'$match': {'_project.is_private': False}},
# Don't return the entire project for each node.
{'$project': {'_project': False}},
{'$sort': {'_created': -1}}
])
return list(agg)
def generate_and_store_short_code(node):
nodes_coll = current_app.data.driver.db['nodes']
node_id = node['_id']
@@ -163,265 +186,35 @@ def create_short_code(node) -> str:
return short_code
def short_link_info(short_code):
"""Returns the short link info in a dict."""
short_link = urllib.parse.urljoin(
current_app.config['SHORT_LINK_BASE_URL'], short_code)
return {
'short_code': short_code,
'short_link': short_link,
}
def before_replacing_node(item, original):
check_permissions('nodes', original, 'PUT')
update_file_name(item)
def after_replacing_node(item, original):
"""Push an update to the Algolia index when a node item is updated. If the
project is private, prevent public indexing.
"""
from pillar.celery import search_index_tasks as index
projects_collection = current_app.data.driver.db['projects']
project = projects_collection.find_one({'_id': item['project']})
if project.get('is_private', False):
# Skip index updating and return
return
status = item['properties'].get('status', 'unpublished')
node_id = str(item['_id'])
if status == 'published':
index.node_save.delay(node_id)
else:
index.node_delete.delay(node_id)
def before_inserting_nodes(items):
"""Before inserting a node in the collection we check if the user is allowed
and we append the project id to it.
"""
from pillar.auth import current_user
nodes_collection = current_app.data.driver.db['nodes']
def find_parent_project(node):
"""Recursive function that finds the ultimate parent of a node."""
if node and 'parent' in node:
parent = nodes_collection.find_one({'_id': node['parent']})
return find_parent_project(parent)
if node:
return node
else:
return None
for item in items:
check_permissions('nodes', item, 'POST')
if 'parent' in item and 'project' not in item:
parent = nodes_collection.find_one({'_id': item['parent']})
project = find_parent_project(parent)
if project:
item['project'] = project['_id']
# Default the 'user' property to the current user.
item.setdefault('user', current_user.user_id)
def after_inserting_nodes(items):
for item in items:
# Skip subscriptions for first level items (since the context is not a
# node, but a project).
# TODO: support should be added for mixed context
if 'parent' not in item:
return
context_object_id = item['parent']
if item['node_type'] == 'comment':
nodes_collection = current_app.data.driver.db['nodes']
parent = nodes_collection.find_one({'_id': item['parent']})
# Always subscribe to the parent node
activity_subscribe(item['user'], 'node', item['parent'])
if parent['node_type'] == 'comment':
# If the parent is a comment, we provide its own parent as
# context. We do this in order to point the user to an asset
# or group when viewing the notification.
verb = 'replied'
context_object_id = parent['parent']
# Subscribe to the parent of the parent comment (post or group)
activity_subscribe(item['user'], 'node', parent['parent'])
else:
activity_subscribe(item['user'], 'node', item['_id'])
verb = 'commented'
elif item['node_type'] in PILLAR_NAMED_NODE_TYPES:
verb = 'posted'
activity_subscribe(item['user'], 'node', item['_id'])
else:
# Don't automatically create activities for non-Pillar node types,
# as we don't know what would be a suitable verb (among other things).
continue
activity_object_add(
item['user'],
verb,
'node',
item['_id'],
'node',
context_object_id
)
def deduct_content_type(node_doc, original=None):
"""Deduct the content type from the attached file, if any."""
if node_doc['node_type'] != 'asset':
log.debug('deduct_content_type: called on node type %r, ignoring', node_doc['node_type'])
return
node_id = node_doc.get('_id')
try:
file_id = ObjectId(node_doc['properties']['file'])
except KeyError:
if node_id is None:
# Creation of a file-less node is allowed, but updates aren't.
return
log.warning('deduct_content_type: Asset without properties.file, rejecting.')
raise wz_exceptions.UnprocessableEntity('Missing file property for asset node')
files = current_app.data.driver.db['files']
file_doc = files.find_one({'_id': file_id},
{'content_type': 1})
if not file_doc:
log.warning('deduct_content_type: Node %s refers to non-existing file %s, rejecting.',
node_id, file_id)
raise wz_exceptions.UnprocessableEntity('File property refers to non-existing file')
# Guess the node content type from the file content type
file_type = file_doc['content_type']
if file_type.startswith('video/'):
content_type = 'video'
elif file_type.startswith('image/'):
content_type = 'image'
else:
content_type = 'file'
node_doc['properties']['content_type'] = content_type
def nodes_deduct_content_type(nodes):
for node in nodes:
deduct_content_type(node)
def before_returning_node(node):
# Run validation process, since GET on nodes entry point is public
check_permissions('nodes', node, 'GET', append_allowed_methods=True)
# Embed short_link_info if the node has a short_code.
short_code = node.get('short_code')
if short_code:
node['short_link'] = short_link_info(short_code)['short_link']
def before_returning_nodes(nodes):
for node in nodes['_items']:
before_returning_node(node)
def node_set_default_picture(node, original=None):
"""Uses the image of an image asset or colour map of texture node as picture."""
if node.get('picture'):
log.debug('Node %s already has a picture, not overriding', node.get('_id'))
return
node_type = node.get('node_type')
props = node.get('properties', {})
content = props.get('content_type')
if node_type == 'asset' and content == 'image':
image_file_id = props.get('file')
elif node_type == 'texture':
# Find the colour map, defaulting to the first image map available.
image_file_id = None
for image in props.get('files', []):
if image_file_id is None or image.get('map_type') == 'color':
image_file_id = image.get('file')
else:
log.debug('Not setting default picture on node type %s content type %s',
node_type, content)
return
if image_file_id is None:
log.debug('Nothing to set the picture to.')
return
log.debug('Setting default picture for node %s to %s', node.get('_id'), image_file_id)
node['picture'] = image_file_id
def nodes_set_default_picture(nodes):
for node in nodes:
node_set_default_picture(node)
def before_deleting_node(node: dict):
check_permissions('nodes', node, 'DELETE')
def after_deleting_node(item):
from pillar.celery import search_index_tasks as index
index.node_delete.delay(str(item['_id']))
only_for_textures = only_for_node_type_decorator('texture')
@only_for_textures
def texture_sort_files(node, original=None):
"""Sort files alphabetically by map type, with colour map first."""
try:
files = node['properties']['files']
except KeyError:
return
# Sort the map types alphabetically, ensuring 'color' comes first.
as_dict = {f['map_type']: f for f in files}
types = sorted(as_dict.keys(), key=lambda k: '\0' if k == 'color' else k)
node['properties']['files'] = [as_dict[map_type] for map_type in types]
def textures_sort_files(nodes):
for node in nodes:
texture_sort_files(node)
def setup_app(app, url_prefix):
global _tagged
cached = app.cache.memoize(timeout=300)
_tagged = cached(_tagged)
from . import patch
patch.setup_app(app, url_prefix=url_prefix)
app.on_fetched_item_nodes += before_returning_node
app.on_fetched_resource_nodes += before_returning_nodes
app.on_fetched_item_nodes += hooks.before_returning_node
app.on_fetched_resource_nodes += hooks.before_returning_nodes
app.on_replace_nodes += before_replacing_node
app.on_replace_nodes += texture_sort_files
app.on_replace_nodes += deduct_content_type
app.on_replace_nodes += node_set_default_picture
app.on_replaced_nodes += after_replacing_node
app.on_replace_nodes += hooks.before_replacing_node
app.on_replace_nodes += hooks.parse_markdown
app.on_replace_nodes += hooks.texture_sort_files
app.on_replace_nodes += hooks.deduct_content_type
app.on_replace_nodes += hooks.node_set_default_picture
app.on_replaced_nodes += hooks.after_replacing_node
app.on_insert_nodes += before_inserting_nodes
app.on_insert_nodes += nodes_deduct_content_type
app.on_insert_nodes += nodes_set_default_picture
app.on_insert_nodes += textures_sort_files
app.on_inserted_nodes += after_inserting_nodes
app.on_insert_nodes += hooks.before_inserting_nodes
app.on_insert_nodes += hooks.parse_markdowns
app.on_insert_nodes += hooks.nodes_deduct_content_type
app.on_insert_nodes += hooks.nodes_set_default_picture
app.on_insert_nodes += hooks.textures_sort_files
app.on_inserted_nodes += hooks.after_inserting_nodes
app.on_update_nodes += texture_sort_files
app.on_update_nodes += hooks.texture_sort_files
app.on_delete_item_nodes += before_deleting_node
app.on_deleted_item_nodes += after_deleting_node
app.on_delete_item_nodes += hooks.before_deleting_node
app.on_deleted_item_nodes += hooks.after_deleting_node
app.register_api_blueprint(blueprint, url_prefix=url_prefix)

325
pillar/api/nodes/hooks.py Normal file
View File

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

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

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

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

@@ -64,4 +64,13 @@
return this;
};
// jQuery's show() sets display as 'inline', this utility sets it to whatever we want.
// Useful for buttons or links that need 'inline-block' or flex for correct padding and alignment.
$.fn.displayAs = function(display_type) {
if (typeof(display_type) === 'undefined') {
display_type = 'block';
}
this.css('display', display_type);
}
}(jQuery));

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,68 @@ nav.sidebar
top: -1px
left: -19px
z-index: 1
$loader-bar-width: 100px
$loader-bar-height: 2px
.loader-bar
bottom: 0
content: ''
display: none
height: 0
overflow: hidden
position: absolute
visibility: hidden
width: 100%
z-index: 20
&:before
animation: none
background-color: $primary
background-image: linear-gradient(to right, $primary-accent, $primary)
content: ''
display: block
height: $loader-bar-height
left: -$loader-bar-width
position: absolute
width: $loader-bar-width
&.active
display: block
height: $loader-bar-height
visibility: visible
&:before
animation: loader-bar-slide 2s linear infinite
@keyframes loader-bar-slide
from
left: -($loader-bar-width / 2)
width: 3%
50%
width: 20%
70%
width: 70%
80%
left: 50%
95%
left: 120%
to
left: 100%
.progress-bar
background-color: $primary
background-image: linear-gradient(to right, $primary-accent, $primary)
.node-details-description
+node-details-description
@include media-breakpoint-up(lg)
max-width: map-get($grid-breakpoints, "md")
@include media-breakpoint-up(xl)
max-width: map-get($grid-breakpoints, "lg")

View File

@@ -1,7 +1,9 @@
$comments-width-max: 710px
.comments-container
max-width: $comments-width-max
position: relative
width: 100%
#comments-reload
text-align: center
@@ -314,9 +316,6 @@ $comments-width-max: 710px
color: $color-success
.comment-reply
&-container
background-color: $color-background
/* Little gravatar icon on the left */
&-avatar
img
@@ -333,7 +332,7 @@ $comments-width-max: 710px
width: 100%
&-field
background-color: $color-background-dark
background-color: $color-background-light
border-radius: 3px
box-shadow: inset 0 0 2px 0 rgba(darken($color-background-dark, 20%), .5)
display: flex
@@ -342,6 +341,7 @@ $comments-width-max: 710px
textarea
+node-details-description
background-color: $color-background-light
border-bottom-right-radius: 0
border-top-right-radius: 0
border: none
@@ -376,7 +376,6 @@ $comments-width-max: 710px
&.filled
textarea
background-color: $color-background-light
border-bottom: thin solid $color-background
&:focus
@@ -453,12 +452,17 @@ $comments-width-max: 710px
transition: background-color 150ms ease-in-out, color 150ms ease-in-out
width: 100px
// The actual button for submitting the comment.
button.comment-action-submit
align-items: center
background: transparent
border: none
border-top-left-radius: 0
border-bottom-left-radius: 0
color: $color-success
cursor: pointer
display: flex
justify-content: center
flex-direction: column
height: 100%
position: relative
@@ -466,8 +470,12 @@ $comments-width-max: 710px
white-space: nowrap
width: 100%
&:hover
background: rgba($color-success, .1)
&:focus
background: lighten($color-success, 10%)
color: $white
&.submitting
color: $color-info

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
@@ -25,10 +26,11 @@ $color-text-light-primary: rgba($color-text-light, .87) !default
$color-text-light-secondary: rgba($color-text-light, .54) !default
$color-text-light-hint: rgba($color-text-light, .38) !default
$color-primary: #68B3C8 !default
$color-primary: #009eff !default
$color-primary-light: hsl(hue($color-primary), 30%, 90%) !default
$color-primary-dark: hsl(hue($color-primary), 80%, 30%) !default
$color-primary-accent: hsl(hue($color-primary), 100%, 50%) !default
$primary-accent: #0bd
$color-secondary: #f42942 !default
$color-secondary-light: hsl(hue($color-secondary), 30%, 90%) !default
@@ -96,16 +98,16 @@ $screen-xs-max: $screen-sm-min - 1 !default
$screen-sm-max: $screen-md-min - 1 !default
$screen-md-max: $screen-lg-min - 1 !default
$sidebar-width: 50px !default
$sidebar-width: 40px !default
/* Project specifics */
$project_nav-width: 250px !default
$project-sidebar-width: 50px !default
$project_header-height: 50px !default
$project_footer-height: 30px !default
$navbar-height: 50px !default
$navbar-backdrop-height: 600px !default
$project_nav-width: 275px !default
$project_nav-width-lg: $project_nav-width * 1.2 !default
$project_nav-width-md: $project_nav-width
$project_nav-width-sm: $project_nav-width * 0.8 !default
$project_nav-width-xs: 100% !default
$project-sidebar-width: 40px !default
$project_header-height: 40px !default
$node-type-asset_image: #e87d86 !default
$node-type-asset_file: #CC91C7 !default
@@ -125,3 +127,37 @@ $z-index-base: 13 !default
@media (min-width: $screen-lg-min)
width: 1270px
// Bootstrap overrides.
$enable-caret: false
$border-radius: .2rem
$btn-border-radius: $border-radius
$primary: $color-primary
$body-bg: $white
$body-color: $color-text
$color-background-nav: #fff
$link-color: $primary
$font-family-sans-serif: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, Helvetica, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"
$font-size-base: .9rem
$dropdown-border-width: 0
$dropdown-box-shadow: 0 10px 25px rgba($black, .1)
$dropdown-padding-y: 0
$dropdown-item-padding-y: .4rem
// Tooltips.
$tooltip-font-size: 0.83rem
$tooltip-max-width: auto
$tooltip-opacity: 1
$nav-link-height: 37px
$navbar-padding-x: 0
$navbar-padding-y: 0
$grid-breakpoints: (xs: 0,sm: 576px,md: 768px,lg: 1100px,xl: 1500px, xxl: 1800px)

View File

@@ -60,14 +60,13 @@
#node-overlay
#error-container
position: fixed
top: $navbar-height
align-items: flex-start
position: fixed
top: $nav-link-height
#error-box
box-shadow: 0 0 25px rgba(black, .1), 0 0 50px rgba(black, .1)
width: auto
border-top-left-radius: 0
border-top-right-radius: 0
box-shadow: 0 0 25px rgba(black, .1), 0 0 50px rgba(black, .1)
position: relative
width: 100%

View File

@@ -9,7 +9,6 @@
color: $color-primary
cursor: pointer
float: right
font-family: $font-body
height: initial
margin: 0
padding: 8px 10px 0 10px
@@ -25,13 +24,16 @@
color: $color-secondary
#notifications-toggle
color: $color-text
cursor: pointer
font-size: 1.5em
position: relative
user-select: none
> i:before
content: '\e815'
font-size: 1.3em
position: relative
top: 2px
&.has-notifications
> i:before
@@ -46,10 +48,10 @@
border-color: transparent transparent $color-background transparent
border-style: solid
border-width: 0 8px 8px 8px
bottom: -15px
bottom: -10px
height: 0
position: absolute
right: 22px
right: 7px
visibility: hidden
width: 0

View File

@@ -1,20 +1,4 @@
body.organizations
ul#sub-nav-tabs__list
align-items: center
display: flex
li.result
padding: 10px 20px
li.create
margin-left: auto
.dashboard-secondary
.box
+container-box
padding: 10px 20px
margin: 0
#item-details
.organization
label

View File

@@ -409,7 +409,6 @@ a.page-card-cta
display: block
+position-center-translate
+media-xs
display: none
+media-sm
@@ -419,9 +418,6 @@ a.page-card-cta
+media-lg
width: 100%
.services.navbar-backdrop-overlay
background: rgba(black, .5)
.services
.page-card-side
max-width: 500px

View File

@@ -1,22 +1,16 @@
.dashboard-container
section#home,
section#projects
background-color: $color-background
border-bottom-left-radius: 3px
border-bottom-right-radius: 3px
nav#sub-nav-tabs.home,
nav#sub-nav-tabs.projects
background-color: white
border-bottom: thin solid $color-background-dark
li.nav-tabs__list-tab
padding: 15px 20px 10px 20px
section#home
background-color: $color-background-dark
nav.nav-tabs__tab
display: none
background-color: $color-background-light
@@ -287,9 +281,8 @@
flex-direction: column
.title
font-size: 1.2em
padding-bottom: 2px
color: $color-text-dark-primary
padding-bottom: 2px
ul.meta
font-size: .9em

View File

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

File diff suppressed because it is too large Load Diff

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

View File

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

View File

@@ -26,7 +26,6 @@
display: inline-flex
align-items: center
justify-content: center
font-family: $font-body
padding: 5px 12px
border-radius: $roundness
@@ -83,6 +82,15 @@
text-shadow: none
=disabled-stripes
color: $color-text-dark
cursor: not-allowed
background: repeating-linear-gradient(-45deg, lighten($color-text-dark-hint, 15%), lighten($color-text-dark-hint, 15%) 10px, lighten($color-text-dark-hint, 5%) 10px, lighten($color-text-dark-hint, 5%) 20px)
border-color: darken($color-text-dark-hint, 5%)
pointer-events: none
opacity: .6
@mixin overlay($from-color, $from-percentage, $to-color, $to-percentage)
position: absolute
top: 0
@@ -122,24 +130,17 @@
transform: translate(-50%, -50%)
=input-generic
padding: 5px 5px 5px 0
// padding: 5px 5px 5px 0
color: $color-text-dark
box-shadow: none
font-family: $font-body
border: thin solid transparent
border-radius: 0
border-bottom-color: $color-background-dark
background-color: transparent
transition: border-color 150ms ease-in-out, box-shadow 150ms ease-in-out
&:hover
border-bottom-color: $color-background
&:focus
outline: 0
border: thin solid transparent
border-bottom-color: $color-primary
box-shadow: 0 1px 0 0 $color-primary
border-color: $primary
box-shadow: none
=label-generic
color: $color-text-dark-primary
@@ -170,17 +171,25 @@
/* Small but wide: phablets, iPads
** Menu is collapsed, columns stack, no brand */
=media-sm
@media (min-width: #{$screen-tablet}) and (max-width: #{$screen-desktop - 1px})
@include media-breakpoint-up(sm)
@content
/* Tablets portrait.
** Menu is expanded, but columns stack, brand is shown */
=media-md
@media (min-width: #{$screen-desktop})
@include media-breakpoint-up(md)
@content
=media-lg
@media (min-width: #{$screen-lg-desktop})
@include media-breakpoint-up(lg)
@content
=media-xl
@include media-breakpoint-up(xl)
@content
=media-xxl
@include media-breakpoint-up(xxl)
@content
=media-print
@@ -354,7 +363,6 @@
+clearfix
color: darken($color-text-dark, 5%)
font:
family: $font-body
weight: 300
size: 1.2em
@@ -507,28 +515,22 @@
=ribbon
background-color: $color-success
cursor: default
border: thin dashed rgba(white, .5)
color: white
pointer-events: none
font-size: 70%
overflow: hidden
white-space: nowrap
position: absolute
right: -40px
top: 10px
-webkit-transform: rotate(45deg)
-moz-transform: rotate(45deg)
-ms-transform: rotate(45deg)
-o-transform: rotate(45deg)
transform: rotate(45deg)
white-space: nowrap
span
border: thin dashed rgba(white, .5)
color: white
display: block
font-size: 70%
margin: 1px 0
padding: 3px 50px
text:
align: center
transform: uppercase
@mixin text-background($text-color, $background-color, $roundness, $padding)
border-radius: $roundness
@@ -642,9 +644,7 @@
#{$property}: $color-status-review
=sidebar-button-active
background-color: $color-background-nav
box-shadow: inset 2px 0 0 $color-primary
color: white
color: $primary
.flash-on
background-color: lighten($color-success, 50%) !important
@@ -660,3 +660,35 @@
transition: all 1s ease-out
img
transition: all 1s ease-out
.cursor-pointer
cursor: pointer
.user-select-none
user-select: none
.pointer-events-none
pointer-events: none
// Bootstrap has .img-fluid, a class to limit the width of an image to 100%.
// .imgs-fluid below is to be applied on a parent container when we can't add
// classes to the images themselves. e.g. the blog.
.imgs-fluid
img
// Just re-use Bootstrap's mixin here.
+img-fluid
.overflow-hidden
overflow: hidden
=text-gradient($color_from, $color_to)
background: linear-gradient(to right, $color_from, $color_to)
background-clip: text
-webkit-background-clip: text
-webkit-text-fill-color: transparent
=active-gradient
+text-gradient($primary-accent, $primary)
&:before
+text-gradient($primary-accent, $primary)

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +1,58 @@
@import _normalize
// Bootstrap variables and utilities.
@import "../../node_modules/bootstrap/scss/functions"
@import "../../node_modules/bootstrap/scss/variables"
@import "../../node_modules/bootstrap/scss/mixins"
@import _config
@import _utils
// Bootstrap components.
@import "../../node_modules/bootstrap/scss/root"
@import "../../node_modules/bootstrap/scss/reboot"
@import "../../node_modules/bootstrap/scss/type"
@import "../../node_modules/bootstrap/scss/images"
@import "../../node_modules/bootstrap/scss/code"
@import "../../node_modules/bootstrap/scss/grid"
@import "../../node_modules/bootstrap/scss/buttons"
@import "../../node_modules/bootstrap/scss/dropdown"
@import "../../node_modules/bootstrap/scss/custom-forms"
@import "../../node_modules/bootstrap/scss/nav"
@import "../../node_modules/bootstrap/scss/navbar"
@import "../../node_modules/bootstrap/scss/card"
@import "../../node_modules/bootstrap/scss/jumbotron"
@import "../../node_modules/bootstrap/scss/media"
@import "../../node_modules/bootstrap/scss/close"
@import "../../node_modules/bootstrap/scss/modal"
@import "../../node_modules/bootstrap/scss/tooltip"
@import "../../node_modules/bootstrap/scss/utilities"
// Pillar components.
@import "apps_base"
@import "components/base"
@import "components/card"
@import "components/jumbotron"
@import "components/navbar"
@import "components/dropdown"
@import "components/footer"
@import "components/shortcode"
@import "components/flyout"
@import "components/buttons"
@import "components/tooltip"
@import "components/overlay"
@import _comments
@import _error
@import _search
.container-fluid.blog
padding: 0
#blog_container
+media-xs
flex-direction: column
padding-top: 0
display: flex
padding:
bottom: 15px
video
max-width: 100%
@import _notifications
#blog_post-edit-form
padding: 20px
.form-group
position: relative
margin: 0 auto 30px auto
font-family: $font-body
input, textarea, select
+input-generic
@@ -95,7 +120,6 @@
margin-bottom: 15px
border-top: thin solid $color-text-dark-hint
.form-group.description,
.form-group.summary,
.form-group.content
@@ -163,14 +187,10 @@
color: transparent
#blog_post-create-container,
#blog_post-edit-container
padding: 25px
#blog_index-container,
#blog_post-create-container,
#blog_post-edit-container
+container-box
padding: 25px
width: 75%
+media-xs
@@ -185,133 +205,6 @@
+media-lg
width: 100%
.blog_index-header
border-top-left-radius: 3px
border-top-right-radius: 3px
display: block
overflow: hidden
position: relative
text-align: center
width: 100%
img
width: 100%
.blog_index-item
+media-lg
max-width: 780px
+media-md
max-width: 780px
+media-sm
max-width: 780px
margin: 15px auto
&:hover
.item-info a
color: $color-primary
.item-picture
position: relative
width: 100%
max-height: 350px
min-height: 200px
height: auto
overflow: hidden
border-top-left-radius: 3px
border-top-right-radius: 3px
+clearfix
img
+position-center-translate
width: 100%
border-top-left-radius: 3px
border-top-right-radius: 3px
+media-xs
min-height: 150px
+media-sm
min-height: 150px
+media-md
min-height: 250px
+media-lg
min-height: 250px
.item-title
color: $color-text-dark
display: block
font:
family: $font-body
size: 1.8em
padding: 10px 25px 10px
ul.meta
+list-meta
font-size: .9em
padding: 0px 25px 5px
.item-content
+node-details-description
font-size: 1.3em
padding: 15px 25px 25px
+media-xs
padding:
left: 0
right: 0
img
display: block
margin: 0 auto
.item-meta
color: $color-text-dark-secondary
padding:
left: 25px
right: 25px
+media-xs
padding:
left: 10px
right: 10px
.button-create,
.button-edit
+button($color-success, 3px, true)
.item-picture+.button-back+.button-edit
right: 20px
top: 20px
.comments-container
padding:
left: 20px
right: 20px
max-width: 680px
margin: 0 auto
+media-lg
padding:
left: 0
right: 0
.comment-reply-container
background-color: transparent
.comment-reply-field
textarea, .comment-reply-meta
background-color: $color-background-light
&.filled
.comment-reply-meta
background-color: $color-success
.comment-reply-form
+media-xs
padding:
left: 0
#blog_post-edit-form
padding: 0
@@ -346,294 +239,3 @@
.form-upload-file-meta
width: initial
#blog_post-edit-title
padding: 0
color: $color-text
font:
size: 1.8em
weight: 300
margin: 0 20px 15px 0
#blog_index-sidebar
width: 25%
padding: 0 15px
+media-xs
width: 100%
clear: both
display: block
margin-top: 25px
+media-sm
width: 40%
+media-md
width: 30%
+media-lg
width: 25%
.button-create
display: block
width: 100%
+button($color-success, 6px)
margin: 0
.button-back
+button($color-info, 6px, true)
display: block
width: 100%
margin: 15px 0 0 0
#blog_post-edit-form
.form-group
.form-control
background-color: white
.blog_index-sidebar,
.blog_project-sidebar
+container-box
background-color: lighten($color-background, 5%)
padding: 20px
.blog_project-card
position: relative
width: 100%
border-radius: 3px
overflow: hidden
background-color: white
color: lighten($color-text, 10%)
box-shadow: 0 0 30px rgba(black, .2)
margin:
top: 0
bottom: 15px
left: auto
right: auto
a.item-header
position: relative
width: 100%
height: 100px
display: block
background-size: 100% 100%
overflow: hidden
.overlay
z-index: 1
width: 100%
height: 100px
@include overlay(transparent, 0%, white, 100%)
img.background
width: 100%
transform: scale(1.4)
.card-thumbnail
position: absolute
z-index: 2
height: 90px
width: 90px
display: block
top: 35px
left: 50%
transform: translateX(-50%)
background-color: white
border-radius: 3px
overflow: hidden
&:hover
img.thumb
opacity: .9
img.thumb
width: 100%
border-radius: 3px
transition: opacity 150ms ease-in-out
+position-center-translate
.item-info
padding: 10px 20px
background-color: white
border-bottom-left-radius: 3px
border-bottom-right-radius: 3px
a.item-title
display: inline-block
width: 100%
padding: 30px 0 15px 0
color: $color-text-dark
text-align: center
font:
size: 1.6em
weight: 300
transition: color 150ms ease-in-out
&:hover
text-decoration: none
color: $color-primary
#blog_container
&.cloud-blog
#blog_index-container,
#blog_post-create-container,
#blog_post-edit-container
width: 100%
padding: 25px 30px 20px 30px
#blog_index-container+#blog_index-sidebar
display: none
#blog_index-container,
&.cloud-blog #blog_index-container
+media-sm
width: 100%
+media-xs
width: 100%
padding: 0 0 50px 0
margin: 0 auto
.blog_index-item
+media-xs
width: 100%
padding: 10px 25px
&.list
margin: 0 auto
padding: 15px 0
margin: 0 auto
border-bottom: thin solid $color-background
&:last-child
border-bottom: none
+media-xs
width: 100%
padding: 15px 10px
margin: 0
a.item-title
padding:
top: 0
bottom: 5px
font:
size: 1.6em
weight: 400
family: $font-body
.item-info
color: $color-text-dark-secondary
font-size: .9em
padding:
left: 25px
right: 25px
.item-header
width: 50px
height: 50px
position: absolute
top: 20px
border-radius: 3px
background-color: $color-background
overflow: hidden
img
+position-center-translate
width: 100%
i
+position-center-translate
font-size: 1.2em
color: $color-text-dark-hint
&.nothumb
border-radius: 50%
a.item-title, .item-info
padding-left: 70px
#blog_index-container
.blog_index-item
position: relative
+media-xs
padding: 25px 0 20px 0
&.list
padding: 15px 10px
margin: 0
+media-xs
width: 100%
padding: 15px 10px
margin: 0
.blog-archive-navigation
+media-xs
font-size: 1em
max-width: initial
border-bottom: thin solid $color-background-dark
display: flex
font:
size: 1.2em
weight: 300
margin: 0 auto
max-width: 780px
text-align: center
+text-overflow-ellipsis
&:last-child
border: none
a, span
+media-xs
padding: 10px
flex: 1
padding: 25px 15px
span
color: $color-text-dark-secondary
pointer-events: none
.blog-action
display: flex
padding: 10px
position: absolute
right: 0
top: 0
z-index: 1
// Specific tweaks for blogs in the context of a project
#project_context
.blog_index-item
+media-xs
margin-left: 0
padding: 0
margin-left: 10px
&.list
margin-left: 35px !important
.item-title,
.item-info
+media-xs
padding-left: 0
padding-left: 25px
#blog_container
.comments-container
+media-sm
margin-left: 10px
margin-left: 30px
.blog-archive-navigation
margin-left: 35px

View File

@@ -0,0 +1,72 @@
.alert
margin-bottom: 0
text-align: center
padding: 10px 20px
z-index: 16
// overriden by alert types
color: $color-text-dark
background-color: $color-background
&.alert-danger,
&.alert-error
background-color: lighten($color-danger, 35%)
color: $color-danger
.alert-icon, .close
color: $color-danger
&.alert-warning
background-color: lighten($color-warning, 20%)
color: darken($color-warning, 20%)
.alert-icon, .close
color: darken($color-warning, 20%)
&.alert-success
background-color: lighten($color-success, 45%)
color: $color-success
.alert-icon, .close
color: $color-success
&.alert-info
background-color: lighten($color-info, 30%)
color: darken($color-info, 10%)
.alert-icon, .close
color: darken($color-info, 10%)
button.close
position: absolute
right: 10px
i
font-size: .8em
i.alert-icon
&:before
font-family: "pillar-font"
padding-right: 10px
&.success:before
content: '\e801'
&.info:before
content: "\e80c"
&.warning:before
content: "\e80b"
&.danger:before,
&.error:before
content: "\e83d"
/* When there's an alert, disable the fixed top */
.alert+.navbar-fixed-top
position: relative
margin-bottom: 0
&+.container
padding-top: 0
.alert+.navbar
position: relative
.alert+.navbar+.page-content
padding-top: 0

View File

@@ -0,0 +1,29 @@
body
height: 100%
+media-sm
width: 100%
max-width: 100%
min-width: auto
+media-xs
width: 100%
max-width: 100%
min-width: auto
.container
+media-xs
max-width: 100%
min-width: auto
padding:
left: 0
right: 0
&.box
+container-box
.page-content
background-color: $white
.container-box
+container-box

View File

@@ -0,0 +1,14 @@
.btn-outline
background-color: transparent
border-width: 1px
transition: background-color .1s
&:focus, &:active
box-shadow: none
.btn-empty
background-color: transparent
border-color: transparent
&:focus, &:active
box-shadow: none

View File

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

View File

@@ -0,0 +1,8 @@
.checkbox label label
padding-left: 0
.checkbox label input[type=checkbox] + label
transition: color 100ms ease-in-out
.checkbox label input[type=checkbox]:checked + label
color: $color-success !important

View File

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

View File

@@ -0,0 +1,25 @@
/* Flyouts (only used on notifications for now) */
.flyout
background-color: $color-background
border-radius: 3px
border: thin solid darken($color-background, 3%)
box-shadow: 1px 2px 2px rgba(black, .2)
display: block
font-size: .9em
& .flyout-title
cursor: default
display: block
float: left
font-size: 1.1em
font-weight: 600
padding: 8px 10px 5px 10px
&.notifications
max-height: 1000%
overflow-x: hidden
position: absolute
right: 0
top: 40px
width: 420px
z-index: 9999

View File

@@ -0,0 +1,117 @@
/* FOOTER */
.footer-wrapper
background-color: $color-background
position: relative
&:after
background-color: $color-background
bottom: 0
content: ''
position: fixed
left: 0
right: 0
top: 0
pointer-events: none
z-index: -1
/* Footer Navigation */
footer
font-size: .75em
padding: 0 0 10px 0
a
color: $color-text-dark-primary
&:hover
color: $color-primary
ul.links
float: left
padding: 0
margin: 0
list-style-type: none
li
padding: 0 15px 0 0
margin: 0
float: left
#hop
display: flex
align-items: center
justify-content: center
visibility: hidden
position: fixed
right: 25px
bottom: 25px
z-index: 999
cursor: pointer
opacity: 0
background: $color-background-light
width: 32px
height: 32px
border-radius: 50%
color: $color-text-dark-secondary
font-size: 2em
box-shadow: 0 0 15px rgba(black, .2)
transform: scale(0.5)
transition: all 150ms ease-in-out
&:hover
transform: scale(1.2)
background-color: $color-background-nav
&.active
visibility: visible
opacity: 1
transform: scale(1)
.footer-navigation
font-size: .85em
margin-bottom: 5px
color: lighten($color-text, 30%)
border-top: thick solid lighten($color-text, 60%)
padding:
top: 15px
bottom: 15px
a
color: lighten($color-text, 35%)
&:hover
color: $color-primary
.footer-links
i
font-size: 80%
position: absolute
left: -14px
top: 20%
.special
padding:
top: 10px
bottom: 15px
font-size: .9em
border-left: thin solid darken($color-background, 20%)
img
max-width: 100%
opacity: .6
ul.footer-social
width: 100%
text-align:center
margin: 0 auto
display: flex
align-items: center
justify-content: space-around
li
display: inline-block
padding: 30px 0
i
font-size: 3em

View File

@@ -0,0 +1,132 @@
/* File Upload forms */
.fieldlist
list-style: none
padding: 0
margin: 10px 0 0 0
li.fieldlist-item
background-color: $color-background-light
border: thin solid $color-background
border-left: 3px solid $color-primary
border-top-right-radius: 3px
border-bottom-right-radius: 3px
margin-bottom: 10px
padding: 10px
+clearfix
.form-group
margin-bottom: 0 !important // override bs
width: 100%
input.form-control
background-color: white !important
padding: 0 10px !important
border: thin solid $color-background-dark !important
div[class$="slug"]
width: 50%
float: left
display: flex
align-items: center
label
margin-right: 10px
.fieldlist-action-button
+button($color-success, 3px)
margin: 0 0 0 10px
padding: 5px 10px
text-transform: initial
.form-upload-file
margin-bottom: 10px
display: flex
flex-direction: column
.form-upload-progress
margin-top: 10px
.form-upload-progress-bar
margin-top: 5px
background-color: $color-success
height: 5px
min-width: 0
border-radius: 3px
&.progress-uploading
background-color: hsl(hue($color-success), 80%, 65%) !important
&.progress-processing
+stripes($color-success, lighten($color-success, 15%), -45deg, 25px)
+stripes-animate
animation-duration: 1s
&.progress-error
background-color: $color-danger !important
.preview-thumbnail
width: 50px
height: 50px
min-width: 50px
min-height: 50px
margin-right: 10px
margin-top: 5px
border-radius: 3px
background-color: $color-background
.form-upload-file-meta-container
display: flex
.form-upload-file-meta
list-style: none
padding: 0
margin: 0
width: 100%
display: flex
flex-wrap: wrap
flex: 1
li
display: inline-block
padding: 5px 10px
&:first-child
padding-left: 0
&.dimensions, &.size
color: $color-text-dark-secondary
&.delete
margin-left: auto
&.name
+text-overflow-ellipsis
.file_delete
color: $color-danger
.form-upload-file-actions
list-style: none
padding: 0
margin: 0
display: flex
flex-wrap: wrap
li
display: inline-block
padding: 5px 10px
.file_delete
color: $color-danger
.form-group
&.error
.form-control, input
border-color: $color-danger !important
ul.error
padding: 5px 0 0 0
margin: 0
color: $color-danger
list-style-type: none

View File

@@ -0,0 +1,38 @@
/* Inputs */
input, input.form-control,
textarea, textarea.form-control,
select, select.form-control
+input-generic
label, label.control-label
+label-generic
select, select.form-control
border-top-left-radius: 3px
border-top-right-radius: 3px
background-color: $color-background-light
option
background-color: white
input.fileupload
background-color: transparent
display: block
margin-top: 10px
textarea
resize: vertical
button, .btn
&.disabled
opacity: .5 !important
pointer-events: none !important
text-shadow: none !important
user-select: none !important
.input-group-flex
display: flex
.input-group-separator
margin: 10px 0
border-top: thin solid $color-background

View File

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

View File

@@ -0,0 +1,249 @@
/* Top level navigation bar. */
.navbar
box-shadow: inset 0 -2px $color-background
.nav
border: none
color: $color-text-dark-secondary
padding: 0
z-index: $z-index-base + 5
nav
margin-left: auto
margin-right: 0
.navbar-nav
margin-right: 0
+media-xs
margin: 0
width: 100%
li
user-select: none
position: relative
img.gravatar
height: 28px
position: relative
width: 28px
.special
background-color: white
border-radius: 999em
box-shadow: 1px 1px 1px rgba(black, .2)
display: inline-block
font-size: 1.2em
height: 18px
left: 28px
position: absolute
top: 3px
width: 18px
z-index: 2
&.subscriber
background-color: $color-success
color: white
font-size: .6em
&.demo
background-color: $color-info
color: white
font-size: .6em
&.none
color: $color-danger
i
+position-center-translate
.dropdown
.navbar-item
&:hover
box-shadow: none // Remove the blue underline usually on navbar, from dropdown items.
ul.dropdown-menu
li
a
white-space: nowrap
.subitem // e.g. "Not Sintel? Log out"
font-size: .8em
text-transform: initial
i
width: 30px
&.subscription-status
a, a:hover
color: $white
&.none
background-color: $color-danger
&.subscriber
background-color: $color-success
&.demo
background-color: $color-info
span.info
display: block
span.renew
display: block
font-size: .9em
.nav-link
@extend .d-flex
.nav-title
white-space: nowrap
.navbar-item
align-items: center
display: flex
user-select: none
color: inherit
+media-sm
padding-left: 10px
padding-right: 10px
&:hover, &:focus
color: $primary
background-color: transparent
box-shadow: inset 0 -3px 0 $primary
text-decoration: none
&:focus
box-shadow: inset 0 -3px 0 $primary
&.active
color: $primary
box-shadow: inset 0 -3px 0 $primary
/* Secondary navigation. */
$nav-secondary-bar-size: -2px
.nav-secondary
align-items: center
box-shadow: inset 0 $nav-secondary-bar-size 0 0 $color-background
.nav-link
color: $color-text
cursor: pointer
transition: color 150ms ease-in-out
&:after
background-color: transparent
bottom: 0
content: ''
height: 2px
position: absolute
right: 0
left: 0
width: 0
transition: width 150ms ease-in-out
.nav-link:hover,
.nav-link.active,
.nav-item.dropdown.show > .nav-link
// Blue bar on the bottom.
&:after
background-color: $primary-accent
background-image: linear-gradient(to right, $primary-accent 70%, $primary)
height: 2px
width: 100%
span
+active-gradient
i
color: $primary-accent
&.nav-secondary-vertical
align-items: flex-start
flex-direction: column
box-shadow: none // Last item on the list already has a box-shadow.
> li
width: 100% // span across the whole width.
// Blue bar on the side.
.nav-link
&:hover,
&.active
color: $primary
@extend .bg-white
&:after
background-image: linear-gradient($primary-accent 70%, $primary)
height: 100%
left: initial
top: 0
width: 3px
// Big navigation dropdown.
.nav-main
.nav-secondary
.nav-link
@extend .pr-5
box-shadow: none
&.nav-see-more
color: $primary
i, span
+active-gradient
.navbar-overlay
+media-lg
display: block
bottom: 0
display: none
left: 0
height: 100%
position: absolute
right: 0
top: 0
transition: background-color 350ms ease-in-out
width: 100%
z-index: 0
&.is-active
background-color: $color-background-nav
text-shadow: none
nav.navbar
.navbar-collapse
> ul > li > .navbar-item
padding: $navbar-nav-link-padding-x
height: $nav-link-height
.navbar-backdrop-container
width: 100%
height: 100%
position: absolute
top: 0
left: 0
right: 0
bottom: 0
img
display: none
position: fixed
width: 100%
align-self: flex-start
+media-md
display: block
+media-lg
display: block
.nav-tabs .dropdown-menu, .nav-pills .dropdown-menu
margin-top: 0
.navbar+.page-content
padding-top: $nav-link-height

View File

@@ -0,0 +1,75 @@
#page-overlay
background-color: rgba(black, .8)
position: fixed
top: 0
bottom: 0
right: 0
left: 0
z-index: $z-index-base + 15
visibility: hidden
opacity: 0
transition: opacity 150ms ease-in-out
display: flex
align-items: center
justify-content: center
img
user-select: none
display: block
max-height: 96%
max-width: 96%
z-index: 0
box-shadow: 0 0 15px rgba(black, .2), 0 0 100px rgba(black, .5)
p.caption
position: absolute
bottom: 1%
&.active
visibility: visible
opacity: 1
.no-preview
user-select: none
z-index: 0
color: $color-text-light-secondary
.nav-prev, .nav-next
display: block
font:
family: 'pillar-font'
size: 2em
height: 80%
width: 50px
cursor: pointer
color: $color-text-light-secondary
z-index: 1
+position-center-translate
&:hover
color: $color-text-light
&:before, &:after
+position-center-translate
.nav-prev
left: 50px
&:before
content: '\e839'
.nav-next
left: initial
right: 0
&:before
content: '\e83a'
&.video
.video-embed
+position-center-translate
position: fixed
iframe
width: 853px
height: 480px

View File

@@ -0,0 +1,26 @@
.popover
background-color: lighten($color-background-nav, 5%)
border-radius: 3px
box-shadow: 1px 1px 2px rgba(black, .2)
border: thin solid lighten($color-background-nav, 10%)
&.in
opacity: 1
.popover-title
background-color: lighten($color-background-nav, 10%)
border-bottom: thin solid lighten($color-background-nav, 3%)
color: $color-text-light-primary
.popover-content
color: $color-text-light
font-size: .9em
&.top .arrow:after
border-top-color: lighten($color-background-nav, 5%)
&.bottom .arrow:after
border-bottom-color: lighten($color-background-nav, 5%)
&.left .arrow:after
border-left-color: lighten($color-background-nav, 5%)
&.right .arrow:after
border-right-color: lighten($color-background-nav, 5%)

View File

@@ -0,0 +1,87 @@
#search-overlay
position: absolute
top: 0
left: 0
right: 0
bottom: 0
width: 100%
height: 100%
pointer-events: none
visibility: hidden
opacity: 0
z-index: $z-index-base + 4
transition: opacity 150ms ease-in-out
&.active
opacity: 1
visibility: visible
background-color: rgba($color-background-nav, .7)
.search-input
+media-lg
max-width: 350px
+media-md
max-width: 350px
+media-sm
max-width: 120px
+media-xs
display: block
margin: 0 10px
position: absolute
z-index: $z-index-base
right: 5px
position: relative
float: left
padding: 0
margin: 0
.search-icon
position: absolute
top: 4px
left: 10px
cursor: pointer
&:after
@extend .tooltip-inner
content: 'Use advanced search...'
font-size: .85em
font-style: normal
left: -10px
opacity: 0
pointer-events: none
position: absolute
top: 30px
transition: top 150ms ease-in-out, opacity 150ms ease-in-out
width: 150px
&:hover
&:after
opacity: 1
top: 35px
#cloud-search, .tt-hint
+text-overflow-ellipsis
border: thin solid $color-background
border-radius: 3px
font:
size: 1em
weight: 400
margin: 0
min-height: 32px
outline: none
padding: 0 20px 0 40px
transition: border 100ms ease-in-out
&:focus
box-shadow: none
border: none
&::placeholder
color: rgba($color-text, .5)
transition: color 150ms ease-in-out
&:hover
&::placeholder
color: rgba($color-text, .6)

View File

@@ -0,0 +1,6 @@
p.shortcode.nocap
padding: 0.6em 3em
font-size: .8em
color: $color-text-dark-primary
background-color: $color-background-dark
border-radius: 3px

View File

@@ -0,0 +1,21 @@
#status-bar
opacity: 0
transition: all 250ms ease-in-out
i
margin-right: 5px
&.info
color: $color-info
&.error
color: $color-danger
&.warning
color: $color-warning
&.success
color: $color-success
&.default
color: $color-text-light
&.active
opacity: 1

View File

@@ -0,0 +1,5 @@
.tooltip
transition: none
.tooltip-inner
white-space: nowrap

View File

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

View File

@@ -1,15 +1,84 @@
@import _normalize
// Bootstrap variables and utilities.
@import "../../node_modules/bootstrap/scss/functions"
@import "../../node_modules/bootstrap/scss/variables"
@import "../../node_modules/bootstrap/scss/mixins"
@import _config
@import _utils
// Bootstrap components.
@import "../../node_modules/bootstrap/scss/root"
@import "../../node_modules/bootstrap/scss/reboot"
@import "../../node_modules/bootstrap/scss/type"
@import "../../node_modules/bootstrap/scss/images"
@import "../../node_modules/bootstrap/scss/code"
@import "../../node_modules/bootstrap/scss/grid"
@import "../../node_modules/bootstrap/scss/tables"
@import "../../node_modules/bootstrap/scss/forms"
@import "../../node_modules/bootstrap/scss/buttons"
@import "../../node_modules/bootstrap/scss/transitions"
@import "../../node_modules/bootstrap/scss/dropdown"
@import "../../node_modules/bootstrap/scss/button-group"
@import "../../node_modules/bootstrap/scss/input-group"
@import "../../node_modules/bootstrap/scss/custom-forms"
@import "../../node_modules/bootstrap/scss/nav"
@import "../../node_modules/bootstrap/scss/navbar"
@import "../../node_modules/bootstrap/scss/card"
@import "../../node_modules/bootstrap/scss/breadcrumb"
@import "../../node_modules/bootstrap/scss/pagination"
@import "../../node_modules/bootstrap/scss/badge"
@import "../../node_modules/bootstrap/scss/jumbotron"
@import "../../node_modules/bootstrap/scss/alert"
@import "../../node_modules/bootstrap/scss/progress"
@import "../../node_modules/bootstrap/scss/media"
@import "../../node_modules/bootstrap/scss/list-group"
@import "../../node_modules/bootstrap/scss/close"
@import "../../node_modules/bootstrap/scss/modal"
@import "../../node_modules/bootstrap/scss/tooltip"
@import "../../node_modules/bootstrap/scss/popover"
@import "../../node_modules/bootstrap/scss/carousel"
@import "../../node_modules/bootstrap/scss/utilities"
@import "../../node_modules/bootstrap/scss/print"
// Pillar components.
@import "apps_base"
@import "components/base"
@import "components/jumbotron"
@import "components/alerts"
@import "components/navbar"
@import "components/dropdown"
@import "components/footer"
@import "components/shortcode"
@import "components/statusbar"
@import "components/search"
@import "components/flyout"
@import "components/forms"
@import "components/inputs"
@import "components/buttons"
@import "components/popover"
@import "components/tooltip"
@import "components/checkbox"
@import "components/overlay"
@import "components/card"
/* Generic styles (comments, notifications, etc) come from base.css */
@import _notifications
@import _comments
@import _project
@import _project-sharing
@import _project-dashboard
@import _user
@import _search
@import _organizations
@import _search
/* services, about, etc */
@import _pages

View File

@@ -1,9 +1,8 @@
/* jsTree overrides */
$tree-color-text: $color-text-dark-primary
$tree-color-highlight: hsl(hue($color-background-active), 40%, 50%)
$tree-color-highlight-background: hsl(hue($color-background-active), 40%, 50%)
$tree-color-highlight-background-text: white
$tree-color-highlight: $color-primary-accent
$tree-color-highlight-background: $white
$tree-color-highlight-background-text: $primary
.jstree-default
/* list item */
@@ -34,11 +33,10 @@ $tree-color-highlight-background-text: white
&[data-node-type="page"],
&[data-node-type="blog"]
color: darken($tree-color-highlight, 5%)
font-weight: bold
.jstree-anchor
padding: 5px 8px 1px 8px
padding: 0 6px
&:after
top: 3px !important
@@ -63,49 +61,48 @@ $tree-color-highlight-background-text: white
&.jstree-open
/* Text of children for an open tree (like a folder) */
.jstree-children > .jstree-node
padding-left: 15px !important
padding-left: 16px !important
.jstree-icon:empty
left: 20px !important
// Tweaks for specific icons
&.pi-file-archive
left: 22px !important
left: 25px !important
&.pi-folder
left: 21px !important
left: 20px !important
font-size: .9em !important
&.pi-film-thick
left: 22px !important
&.pi-splay
left: 20px !important
font-size: .85em !important
.jstree-anchor
box-shadow: inset 1px 0 0 0 rgba($tree-color-text, .2)
// box-shadow: inset 1px 0 0 0 $color-background
/* Closed Folder */
// &.jstree-closed
&.jstree-open .jstree-icon.jstree-ocl,
&.jstree-closed .jstree-icon.jstree-ocl
float: left
min-width: 30px
opacity: 0
position: absolute
z-index: 1
opacity: 0
min-width: 30px
float: left
/* The text of the last level item */
.jstree-anchor
+media-xs
width: 98%
padding: 0 !important
width: 98%
border: none
font-size: 13px
height: inherit
line-height: 26px
line-height: 24px
overflow: hidden
padding-left: 28px
padding-right: 10px
text-overflow: ellipsis
transition: none
transition: color 50ms ease-in-out, background-color 100ms ease-in-out
white-space: nowrap
width: 100%
@@ -113,7 +110,7 @@ $tree-color-highlight-background-text: white
&:after
content: '\e83a' !important
font-family: 'pillar-font'
color: white
color: $tree-color-highlight-background-text
display: none
position: absolute
right: 7px
@@ -121,31 +118,31 @@ $tree-color-highlight-background-text: white
// Icon, not selected
.jstree-icon
color: $color-text-dark-secondary
font-size: 95% !important
color: $tree-color-text
margin: 0 !important
/* Selected item */
&.jstree-clicked
background-color: $tree-color-highlight-background !important
color: white !important
color: $tree-color-highlight-background-text !important
font-weight: bold
&:after
display: block
color: white !important
color: $tree-color-highlight-background-text !important
.jstree-ocl,
.jstree-icon
color: white
color: $tree-color-highlight-background-text
/* hover an active item */
&.jstree-hovered
background-color: lighten($tree-color-highlight-background, 10%) !important
box-shadow: none
color: white !important
color: $tree-color-highlight-background-text !important
&.jstree-hovered .jstree-icon
color: white !important
color: $tree-color-highlight-background-text !important
.jstree-hovered
background-color: rgba($tree-color-highlight, .1) !important
@@ -184,8 +181,8 @@ $tree-color-highlight-background-text: white
position: absolute
&:empty
line-height: 26px
left: 5px
line-height: 24px
left: 3px
&.is_subscriber
.jstree-node
@@ -269,7 +266,7 @@ $tree-color-highlight-background-text: white
.jstree-default .jstree-node.jstree-closed .jstree-icon.jstree-ocl + .jstree-anchor,
.jstree-default .jstree-node.jstree-open .jstree-icon.jstree-ocl + .jstree-anchor
padding-left: 28px !important
padding-left: 24px !important
/* hovered text */
.jstree-default .jstree-hovered,
@@ -280,11 +277,11 @@ $tree-color-highlight-background-text: white
a.jstree-anchor.jstree-clicked+ul li a.jstree-anchor.jstree-clicked
background-color: rgba($tree-color-highlight-background, .8) !important
color: white !important
color: $tree-color-highlight-background-text !important
a.jstree-anchor.jstree-clicked+ul li a.jstree-anchor.jstree-clicked+ul li a.jstree-anchor.jstree-clicked
background-color: rgba($tree-color-highlight-background, .8) !important
color: white !important
color: $tree-color-highlight-background-text !important
i.jstree-icon.jstree-ocl
color: rgba($tree-color-text, .5) !important

View File

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

View File

@@ -1,14 +0,0 @@
@import _normalize
@import _config
@import _utils
@import _comments
@import _project
@import _project-sharing
@import _project-dashboard
@import _error
@import _search
@import plugins/_jstree
@import plugins/_js_select2

View File

@@ -7,8 +7,7 @@ $color-theatre-background-dark: darken($color-theatre-background, 5%)
$theatre-width: 350px
body.theatre,
body.theatre .container-page
body.theatre
background-color: $color-theatre-background
nav.navbar
+media-lg
@@ -26,6 +25,7 @@ body.theatre .container-page
display: flex
align-items: center
justify-content: center
.page-body
height: 100%
width: 100%

View File

@@ -7,19 +7,21 @@
| {% set node_type_name = 'folder' %}
| {% endif %}
li(class="button-{{ node_type['name'] }}")
a.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'] }}")
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="pi-{{ node_type['name'] }}")
| {% if node_type_name == 'group_texture' %}
| Texture Folder
| {% elif node_type_name == 'group_hdri' %}
| HDRi Folder
| {% else %}
| {{ node_type_name }}
span.text-capitalize
|{{ node_type_name }}
| {% endif %}
| {% endif %}
| {% endfor %}

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

@@ -28,15 +28,15 @@
span Add files...
input(type='file', name='file', multiple='')
button.btn.btn-primary.start(type='submit')
button.btn.btn-outline-primary.start(type='submit')
i.pi-upload
span Start upload
span Start Upload
button.btn.btn-warning.cancel(type='reset')
button.btn.btn-outline-warning.cancel(type='reset')
i.pi-cancel
span Cancel upload
span Cancel Upload
button.btn.btn-danger.delete(type='button')
button.btn.btn-outline-danger.delete(type='button')
i.pi-trash
span Delete

View File

@@ -23,7 +23,7 @@ script#template-upload(type="text/x-tmpl").
</button>
{% } %}
{% if (!i) { %}
<button class="btn btn-warning cancel">
<button class="btn btn-outline-secondary cancel">
<i class="ion-close-round"></i>
<span>Cancel</span>
</button>
@@ -61,7 +61,7 @@ script#template-download(type="text/x-tmpl").
</td>
<td>
{% if (file.deleteUrl) { %}
<button class="btn btn-danger delete" data-type="{%=file.deleteType%}" data-url="{%=file.deleteUrl%}"{% if (file.deleteWithCredentials) { %} data-xhr-fields='{"withCredentials":true}'{% } %}>
<button class="btn btn-outline-danger delete" data-type="{%=file.deleteType%}" data-url="{%=file.deleteUrl%}"{% if (file.deleteWithCredentials) { %} data-xhr-fields='{"withCredentials":true}'{% } %}>
<i class="ion-trash-b"></i>
<span>Delete</span>
</button>
@@ -71,7 +71,7 @@ script#template-download(type="text/x-tmpl").
Create
</div>
{% } else { %}
<button class="btn btn-warning cancel">
<button class="btn btn-outline-secondary cancel">
<i class="ion-close-round"></i>
<span>Cancel</span>
</button>

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