Compare commits
131 Commits
wip-redesi
...
wip-refact
Author | SHA1 | Date | |
---|---|---|---|
77e3c476f0 | |||
842ddaeab0 | |||
85e5cb4f71 | |||
6648f8d074 | |||
a5bc36b1cf | |||
e56b3ec61f | |||
9624f6bd76 | |||
4e5a53a19b | |||
fbc7c0fce7 | |||
bb483e72aa | |||
baf27fa560 | |||
845ba953cb | |||
e5b7905a5c | |||
88c0ef0e7c | |||
f8d992400e | |||
263d68071e | |||
0f7f7d5a66 | |||
6b29c70212 | |||
07670dce96 | |||
fe288b1cc2 | |||
2e9555e160 | |||
b0311af6b5 | |||
35a22cab4b | |||
0055633732 | |||
78b186c8e4 | |||
232321cc2c | |||
a6d662b690 | |||
32c7ffbc99 | |||
cfcc629b61 | |||
8ea0310956 | |||
c1958d2da7 | |||
030c5494a8 | |||
462f31406a | |||
1a1f67cf00 | |||
8d5bdf04aa | |||
9a9d15ce47 | |||
c795015a3c | |||
afda0062f5 | |||
a97c8ffc93 | |||
c5fa6b9535 | |||
2be41a7145 | |||
e8fb77c39b | |||
40933d51cf | |||
9a9ca1bf8b | |||
0983474e76 | |||
6bcce87bb9 | |||
1401a6168f | |||
85eab0c6cb | |||
a753637e70 | |||
f87c7a25df | |||
3ae16d7750 | |||
c546dd2881 | |||
48df0583ab | |||
094d15116e | |||
534d06ca8f | |||
df078b395d | |||
5df92ca4cf | |||
ecace8c55b | |||
bcacdfb7ea | |||
d7fd90ded1 | |||
b9268337c3 | |||
9b62daec74 | |||
5cc5698477 | |||
00ba98d279 | |||
e818c92d4e | |||
612862c048 | |||
6b3f025e16 | |||
8a90cd00e9 | |||
17a69b973e | |||
8380270128 | |||
35225a189d | |||
be98a95fc0 | |||
95c1f913c6 | |||
9bcd6cec89 | |||
4532c1ea39 | |||
e19dd27099 | |||
f54e56bad8 | |||
eb851ce6e1 | |||
586d9c0d3b | |||
ac23c7b00b | |||
811edc5a2a | |||
cb95bf989a | |||
e4fa32b8e4 | |||
08bf63c2ee | |||
bc16bb6e56 | |||
0fcafddbd1 | |||
f29e01c78e | |||
2698be3e12 | |||
3f8e0396cf | |||
05c488c484 | |||
40c19a3cb0 | |||
a67527d6af | |||
791906521f | |||
2ad5b20880 | |||
f6fd9228e5 | |||
e9f303f330 | |||
00a7406a1e | |||
82aa521b5f | |||
f7220924bc | |||
595bb48741 | |||
1c430044b9 | |||
73bc084417 | |||
37ca803162 | |||
939bb97f13 | |||
2c40665271 | |||
e8123b7839 | |||
6d6a40b8c0 | |||
efd345ec46 | |||
d655d2b749 | |||
a58e616769 | |||
a8a7166e78 | |||
1649591d75 | |||
9389fef8ba | |||
6737aa1123 | |||
469f24d113 | |||
8a0f582a80 | |||
559e212c55 | |||
61278730c6 | |||
0fdcbc3947 | |||
8dc3296bd5 | |||
a699138fd6 | |||
7da741f354 | |||
41369d134c | |||
61ed083218 | |||
46777f7f8c | |||
ef94c68177 | |||
aaf452e18b | |||
c607eaf23d | |||
baa77a7de5 | |||
c83a1a21b8 | |||
549cf0a3e8 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -12,6 +12,7 @@ config_local.py
|
||||
|
||||
/build
|
||||
/.cache
|
||||
/.pytest_cache/
|
||||
/*.egg-info/
|
||||
profile.stats
|
||||
/dump/
|
||||
@@ -26,6 +27,7 @@ profile.stats
|
||||
|
||||
pillar/web/static/assets/css/*.css
|
||||
pillar/web/static/assets/js/*.min.js
|
||||
pillar/web/static/assets/js/vendor/video.min.js
|
||||
pillar/web/static/storage/
|
||||
pillar/web/static/uploads/
|
||||
pillar/web/templates/
|
||||
|
63
gulpfile.js
63
gulpfile.js
@@ -16,7 +16,7 @@ var uglify = require('gulp-uglify-es').default;
|
||||
|
||||
var enabled = {
|
||||
uglify: argv.production,
|
||||
maps: argv.production,
|
||||
maps: !argv.production,
|
||||
failCheck: !argv.production,
|
||||
prettyPug: !argv.production,
|
||||
cachify: !argv.production,
|
||||
@@ -32,6 +32,7 @@ var destination = {
|
||||
|
||||
var source = {
|
||||
bootstrap: 'node_modules/bootstrap/',
|
||||
jquery: 'node_modules/jquery/',
|
||||
popper: 'node_modules/popper.js/'
|
||||
}
|
||||
|
||||
@@ -78,10 +79,23 @@ gulp.task('scripts', function() {
|
||||
});
|
||||
|
||||
|
||||
/* Collection of scripts in src/scripts/tutti/ to merge into tutti.min.js */
|
||||
/* Since it's always loaded, it's only for functions that we want site-wide */
|
||||
/* Collection of scripts in src/scripts/tutti/ to merge into tutti.min.js
|
||||
* Since it's always loaded, it's only for functions that we want site-wide.
|
||||
* It also includes jQuery and Bootstrap (and its dependency popper), since
|
||||
* the site doesn't work without it anyway.*/
|
||||
gulp.task('scripts_concat_tutti', function() {
|
||||
gulp.src('src/scripts/tutti/**/*.js')
|
||||
|
||||
toUglify = [
|
||||
source.jquery + 'dist/jquery.min.js',
|
||||
source.popper + 'dist/umd/popper.min.js',
|
||||
source.bootstrap + 'js/dist/index.js',
|
||||
source.bootstrap + 'js/dist/util.js',
|
||||
source.bootstrap + 'js/dist/tooltip.js',
|
||||
source.bootstrap + 'js/dist/dropdown.js',
|
||||
'src/scripts/tutti/**/*.js'
|
||||
];
|
||||
|
||||
gulp.src(toUglify)
|
||||
.pipe(gulpif(enabled.failCheck, plumber()))
|
||||
.pipe(gulpif(enabled.maps, sourcemaps.init()))
|
||||
.pipe(concat("tutti.min.js"))
|
||||
@@ -92,39 +106,17 @@ gulp.task('scripts_concat_tutti', function() {
|
||||
.pipe(gulpif(argv.livereload, livereload()));
|
||||
});
|
||||
|
||||
gulp.task('scripts_concat_markdown', function() {
|
||||
gulp.src('src/scripts/markdown/**/*.js')
|
||||
.pipe(gulpif(enabled.failCheck, plumber()))
|
||||
.pipe(gulpif(enabled.maps, sourcemaps.init()))
|
||||
.pipe(concat("markdown.min.js"))
|
||||
.pipe(gulpif(enabled.uglify, uglify()))
|
||||
.pipe(gulpif(enabled.maps, sourcemaps.write(".")))
|
||||
.pipe(gulpif(enabled.chmod, chmod(644)))
|
||||
.pipe(gulp.dest(destination.js))
|
||||
.pipe(gulpif(argv.livereload, livereload()));
|
||||
});
|
||||
|
||||
/* Simply move these vendor scripts from node_modules. */
|
||||
gulp.task('scripts_move_vendor', function(done) {
|
||||
|
||||
// Combine all needed Bootstrap JavaScript into a single file.
|
||||
gulp.task('scripts_concat_bootstrap', function() {
|
||||
|
||||
toUglify = [
|
||||
source.popper + 'dist/umd/popper.min.js',
|
||||
source.bootstrap + 'js/dist/index.js',
|
||||
source.bootstrap + 'js/dist/util.js',
|
||||
source.bootstrap + 'js/dist/tooltip.js',
|
||||
source.bootstrap + 'js/dist/dropdown.js',
|
||||
let toMove = [
|
||||
'node_modules/video.js/dist/video.min.js',
|
||||
];
|
||||
|
||||
gulp.src(toUglify)
|
||||
.pipe(gulpif(enabled.failCheck, plumber()))
|
||||
.pipe(gulpif(enabled.maps, sourcemaps.init()))
|
||||
.pipe(concat("bootstrap.min.js"))
|
||||
.pipe(gulpif(enabled.uglify, uglify()))
|
||||
.pipe(gulpif(enabled.maps, sourcemaps.write(".")))
|
||||
.pipe(gulpif(enabled.chmod, chmod(644)))
|
||||
.pipe(gulp.dest(destination.js))
|
||||
.pipe(gulpif(argv.livereload, livereload()));
|
||||
gulp.src(toMove)
|
||||
.pipe(gulp.dest(destination.js + '/vendor/'));
|
||||
done();
|
||||
});
|
||||
|
||||
|
||||
@@ -139,9 +131,9 @@ gulp.task('watch',function() {
|
||||
gulp.watch('src/templates/**/*.pug',['templates']);
|
||||
gulp.watch('src/scripts/*.js',['scripts']);
|
||||
gulp.watch('src/scripts/tutti/**/*.js',['scripts_concat_tutti']);
|
||||
gulp.watch('src/scripts/markdown/**/*.js',['scripts_concat_markdown']);
|
||||
});
|
||||
|
||||
|
||||
// Erases all generated files in output directories.
|
||||
gulp.task('cleanup', function() {
|
||||
var paths = [];
|
||||
@@ -164,6 +156,5 @@ gulp.task('default', tasks.concat([
|
||||
'templates',
|
||||
'scripts',
|
||||
'scripts_concat_tutti',
|
||||
'scripts_concat_markdown',
|
||||
'scripts_concat_bootstrap',
|
||||
'scripts_move_vendor',
|
||||
]));
|
||||
|
3597
package-lock.json
generated
3597
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
59
package.json
59
package.json
@@ -1,31 +1,32 @@
|
||||
{
|
||||
"name": "pillar",
|
||||
"license": "GPL-2.0+",
|
||||
"author": "Blender Institute",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git://git.blender.org/pillar.git"
|
||||
},
|
||||
"devDependencies": {
|
||||
"gulp": "^3.9.1",
|
||||
"gulp-autoprefixer": "^6.0.0",
|
||||
"gulp-cached": "^1.1.1",
|
||||
"gulp-chmod": "^2.0.0",
|
||||
"gulp-concat": "^2.6.1",
|
||||
"gulp-if": "^2.0.2",
|
||||
"gulp-git": "^2.8.0",
|
||||
"gulp-livereload": "^4.0.0",
|
||||
"gulp-plumber": "^1.2.0",
|
||||
"gulp-pug": "^4.0.1",
|
||||
"gulp-rename": "^1.4.0",
|
||||
"gulp-sass": "^4.0.1",
|
||||
"gulp-sourcemaps": "^2.6.4",
|
||||
"gulp-uglify-es": "^1.0.4",
|
||||
"minimist": "^1.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"bootstrap": "^4.1.3",
|
||||
"jquery": "^3.3.1",
|
||||
"popper.js": "^1.14.4"
|
||||
}
|
||||
"name": "pillar",
|
||||
"license": "GPL-2.0+",
|
||||
"author": "Blender Institute",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git://git.blender.org/pillar.git"
|
||||
},
|
||||
"devDependencies": {
|
||||
"gulp": "^3.9.1",
|
||||
"gulp-autoprefixer": "^6.0.0",
|
||||
"gulp-cached": "^1.1.1",
|
||||
"gulp-chmod": "^2.0.0",
|
||||
"gulp-concat": "^2.6.1",
|
||||
"gulp-if": "^2.0.2",
|
||||
"gulp-git": "^2.8.0",
|
||||
"gulp-livereload": "^4.0.0",
|
||||
"gulp-plumber": "^1.2.0",
|
||||
"gulp-pug": "^4.0.1",
|
||||
"gulp-rename": "^1.4.0",
|
||||
"gulp-sass": "^4.0.1",
|
||||
"gulp-sourcemaps": "^2.6.4",
|
||||
"gulp-uglify-es": "^1.0.4",
|
||||
"minimist": "^1.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"bootstrap": "^4.1.3",
|
||||
"jquery": "^3.3.1",
|
||||
"popper.js": "^1.14.4",
|
||||
"video.js": "^7.2.2"
|
||||
}
|
||||
}
|
||||
|
@@ -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.
|
||||
|
@@ -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
|
||||
|
@@ -1,8 +1,8 @@
|
||||
import copy
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from bson import ObjectId, tz_util
|
||||
from datetime import datetime
|
||||
import cerberus.errors
|
||||
from eve.io.mongo import Validator
|
||||
from flask import current_app
|
||||
|
||||
@@ -12,6 +12,31 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ValidateCustomFields(Validator):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Will be reference to the actual document being validated, so that we can
|
||||
# modify it during validation.
|
||||
self.__real_document = None
|
||||
|
||||
def validate(self, document, *args, **kwargs):
|
||||
# Keep a reference to the actual document, because Cerberus validates copies.
|
||||
self.__real_document = document
|
||||
result = super().validate(document, *args, **kwargs)
|
||||
|
||||
# Store the in-place modified document as self.document, so that Eve's post_internal
|
||||
# can actually pick it up as the validated document. We need to make a copy so that
|
||||
# further modifications (like setting '_etag' etc.) aren't done in-place.
|
||||
self.document = copy.deepcopy(document)
|
||||
|
||||
return result
|
||||
|
||||
def _get_child_validator(self, *args, **kwargs):
|
||||
child = super()._get_child_validator(*args, **kwargs)
|
||||
# Pass along our reference to the actual document.
|
||||
child.__real_document = self.__real_document
|
||||
return child
|
||||
|
||||
# TODO: split this into a convert_property(property, schema) and call that from this function.
|
||||
def convert_properties(self, properties, node_schema):
|
||||
"""Converts datetime strings and ObjectId strings to actual Python objects."""
|
||||
@@ -73,6 +98,11 @@ class ValidateCustomFields(Validator):
|
||||
dict_property[key] = self.convert_properties(item_prop, item_schema)['item']
|
||||
|
||||
def _validate_valid_properties(self, valid_properties, field, value):
|
||||
"""Fake property that triggers node dynamic property validation.
|
||||
|
||||
The rule's arguments are validated against this schema:
|
||||
{'type': 'boolean'}
|
||||
"""
|
||||
from pillar.api.utils import project_get_node_type
|
||||
|
||||
projects_collection = current_app.data.driver.db['projects']
|
||||
@@ -107,7 +137,8 @@ class ValidateCustomFields(Validator):
|
||||
if val:
|
||||
# This ensures the modifications made by v's coercion rules are
|
||||
# visible to this validator's output.
|
||||
self.current[field] = v.current
|
||||
# TODO(fsiddi): this no longer works due to Cerberus internal changes.
|
||||
# self.current[field] = v.current
|
||||
return True
|
||||
|
||||
log.warning('Error validating properties for node %s: %s', self.document, v.errors)
|
||||
@@ -118,6 +149,9 @@ class ValidateCustomFields(Validator):
|
||||
|
||||
Combine "required_after_creation=True" with "required=False" to allow
|
||||
pre-insert hooks to set default values.
|
||||
|
||||
The rule's arguments are validated against this schema:
|
||||
{'type': 'boolean'}
|
||||
"""
|
||||
|
||||
if not required_after_creation:
|
||||
@@ -125,14 +159,14 @@ class ValidateCustomFields(Validator):
|
||||
# validator at all.
|
||||
return
|
||||
|
||||
if self._id is None:
|
||||
if self.document_id is None:
|
||||
# This is a creation call, in which case this validator shouldn't run.
|
||||
return
|
||||
|
||||
if not value:
|
||||
self._error(field, "Value is required once the document was created")
|
||||
|
||||
def _validate_type_iprange(self, field_name: str, value: str):
|
||||
def _validator_iprange(self, field_name: str, value: str):
|
||||
"""Ensure the field contains a valid IP address.
|
||||
|
||||
Supports both IPv6 and IPv4 ranges. Requires the IPy module.
|
||||
@@ -149,40 +183,36 @@ class ValidateCustomFields(Validator):
|
||||
if ip.prefixlen() == 0:
|
||||
self._error(field_name, 'Zero-length prefix is not allowed')
|
||||
|
||||
def _validate_type_binary(self, field_name: str, value: bytes):
|
||||
"""Add support for binary type.
|
||||
|
||||
This type was actually introduced in Cerberus 1.0, so we can drop
|
||||
support for this once Eve starts using that version (or newer).
|
||||
def _validator_markdown(self, field, value):
|
||||
"""Convert MarkDown.
|
||||
"""
|
||||
my_log = log.getChild('_validator_markdown')
|
||||
|
||||
if not isinstance(value, (bytes, bytearray)):
|
||||
self._error(field_name, f'wrong value type {type(value)}, expected bytes or bytearray')
|
||||
# Find this field inside the original document
|
||||
my_subdoc = self._subdoc_in_real_document()
|
||||
if my_subdoc is None:
|
||||
# If self.update==True we are validating an update document, which
|
||||
# may not contain all fields, so then a missing field is fine.
|
||||
if not self.update:
|
||||
self._error(field, f'validator_markdown: unable to find sub-document '
|
||||
f'for path {self.document_path}')
|
||||
return
|
||||
|
||||
def _validate_coerce(self, coerce, field: str, value):
|
||||
"""Override Cerberus' _validate_coerce method for richer features.
|
||||
|
||||
This now supports named coercion functions (available in Cerberus 1.0+)
|
||||
and passes the field name to coercion functions as well.
|
||||
"""
|
||||
if isinstance(coerce, str):
|
||||
coerce = getattr(self, f'_normalize_coerce_{coerce}')
|
||||
|
||||
try:
|
||||
return coerce(field, value)
|
||||
except (TypeError, ValueError):
|
||||
self._error(field, cerberus.errors.ERROR_COERCION_FAILED.format(field))
|
||||
|
||||
def _normalize_coerce_markdown(self, field: str, value):
|
||||
"""Render Markdown from this field into {field}_html.
|
||||
|
||||
The field name MUST NOT end in `_html`. The Markdown is read from this
|
||||
field and the rendered HTML is written to the field `{field}_html`.
|
||||
"""
|
||||
my_log.debug('validating field %r with value %r', field, value)
|
||||
save_to = pillar.markdown.cache_field_name(field)
|
||||
html = pillar.markdown.markdown(value)
|
||||
field_name = pillar.markdown.cache_field_name(field)
|
||||
self.current[field_name] = html
|
||||
return value
|
||||
my_log.debug('saving result to %r in doc with id %s', save_to, id(my_subdoc))
|
||||
my_subdoc[save_to] = html
|
||||
|
||||
def _subdoc_in_real_document(self):
|
||||
"""Return a reference to the current sub-document inside the real document.
|
||||
|
||||
This allows modification of the document being validated.
|
||||
"""
|
||||
my_subdoc = getattr(self, 'persisted_document') or self.__real_document
|
||||
for item in self.document_path:
|
||||
my_subdoc = my_subdoc[item]
|
||||
return my_subdoc
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
@@ -190,12 +220,12 @@ if __name__ == '__main__':
|
||||
|
||||
v = ValidateCustomFields()
|
||||
v.schema = {
|
||||
'foo': {'type': 'string', 'coerce': 'markdown'},
|
||||
'foo': {'type': 'string', 'validator': 'markdown'},
|
||||
'foo_html': {'type': 'string'},
|
||||
'nested': {
|
||||
'type': 'dict',
|
||||
'schema': {
|
||||
'bar': {'type': 'string', 'coerce': 'markdown'},
|
||||
'bar': {'type': 'string', 'validator': 'markdown'},
|
||||
'bar_html': {'type': 'string'},
|
||||
}
|
||||
}
|
||||
|
@@ -121,12 +121,43 @@ users_schema = {
|
||||
'service': {
|
||||
'type': 'dict',
|
||||
'allow_unknown': True,
|
||||
},
|
||||
|
||||
# Node-specific information for this user.
|
||||
'nodes': {
|
||||
'type': 'dict',
|
||||
'schema': {
|
||||
'badger': {
|
||||
'type': 'list',
|
||||
'schema': {'type': 'string'}
|
||||
}
|
||||
}
|
||||
# Per watched video info about where the user left off, both in time and in percent.
|
||||
'view_progress': {
|
||||
'type': 'dict',
|
||||
# Keyed by Node ID of the video asset. MongoDB doesn't support using
|
||||
# ObjectIds as key, so we cast them to string instead.
|
||||
'keyschema': {'type': 'string'},
|
||||
'valueschema': {
|
||||
'type': 'dict',
|
||||
'schema': {
|
||||
'progress_in_sec': {'type': 'float', 'min': 0},
|
||||
'progress_in_percent': {'type': 'integer', 'min': 0, 'max': 100},
|
||||
|
||||
# When the progress was last updated, so we can limit this history to
|
||||
# the last-watched N videos if we want, or show stuff in chrono order.
|
||||
'last_watched': {'type': 'datetime'},
|
||||
|
||||
# True means progress_in_percent = 100, for easy querying
|
||||
'done': {'type': 'boolean', 'default': False},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
},
|
||||
},
|
||||
|
||||
'badges': {
|
||||
'type': 'dict',
|
||||
'schema': {
|
||||
'html': {'type': 'string'}, # HTML fetched from Blender ID.
|
||||
'expires': {'type': 'datetime'}, # When we should fetch it again.
|
||||
},
|
||||
},
|
||||
|
||||
# Properties defined by extensions. Extensions should use their name (see the
|
||||
@@ -155,7 +186,7 @@ organizations_schema = {
|
||||
'description': {
|
||||
'type': 'string',
|
||||
'maxlength': 256,
|
||||
'coerce': 'markdown',
|
||||
'validator': 'markdown',
|
||||
},
|
||||
'_description_html': {'type': 'string'},
|
||||
'website': {
|
||||
@@ -227,7 +258,7 @@ organizations_schema = {
|
||||
'start': {'type': 'binary', 'required': True},
|
||||
'end': {'type': 'binary', 'required': True},
|
||||
'prefix': {'type': 'integer', 'required': True},
|
||||
'human': {'type': 'iprange', 'required': True},
|
||||
'human': {'type': 'string', 'required': True, 'validator': 'iprange'},
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -292,7 +323,7 @@ nodes_schema = {
|
||||
},
|
||||
'description': {
|
||||
'type': 'string',
|
||||
'coerce': 'markdown',
|
||||
'validator': 'markdown',
|
||||
},
|
||||
'_description_html': {'type': 'string'},
|
||||
'picture': _file_embedded_schema,
|
||||
@@ -327,7 +358,7 @@ nodes_schema = {
|
||||
'properties': {
|
||||
'type': 'dict',
|
||||
'valid_properties': True,
|
||||
'required': True,
|
||||
'required': True
|
||||
},
|
||||
'permissions': {
|
||||
'type': 'dict',
|
||||
@@ -345,11 +376,11 @@ tokens_schema = {
|
||||
},
|
||||
'token': {
|
||||
'type': 'string',
|
||||
'required': False,
|
||||
'required': True,
|
||||
},
|
||||
'token_hashed': {
|
||||
'type': 'string',
|
||||
'required': True,
|
||||
'required': False,
|
||||
},
|
||||
'expire_time': {
|
||||
'type': 'datetime',
|
||||
@@ -368,6 +399,13 @@ tokens_schema = {
|
||||
'type': 'string',
|
||||
},
|
||||
},
|
||||
|
||||
# OAuth scopes granted to this token.
|
||||
'oauth_scopes': {
|
||||
'type': 'list',
|
||||
'default': [],
|
||||
'schema': {'type': 'string'},
|
||||
}
|
||||
}
|
||||
|
||||
files_schema = {
|
||||
@@ -539,7 +577,7 @@ projects_schema = {
|
||||
},
|
||||
'description': {
|
||||
'type': 'string',
|
||||
'coerce': 'markdown',
|
||||
'validator': 'markdown',
|
||||
},
|
||||
'_description_html': {'type': 'string'},
|
||||
# Short summary for the project
|
||||
@@ -833,4 +871,9 @@ UPSET_ON_PUT = False # do not create new document on PUT of non-existant URL.
|
||||
X_DOMAINS = '*'
|
||||
X_ALLOW_CREDENTIALS = True
|
||||
X_HEADERS = 'Authorization'
|
||||
XML = False
|
||||
RENDERERS = ['eve.render.JSONRenderer']
|
||||
|
||||
# TODO(Sybren): this is a quick workaround to make /p/{url}/jstree work again.
|
||||
# Apparently Eve is now stricter in checking against MONGO_QUERY_BLACKLIST, and
|
||||
# blocks our use of $regex.
|
||||
MONGO_QUERY_BLACKLIST = ['$where']
|
||||
|
@@ -94,17 +94,10 @@ def generate_and_store_token(user_id, days=15, prefix=b'') -> dict:
|
||||
|
||||
# Use 'xy' as altargs to prevent + and / characters from appearing.
|
||||
# We never have to b64decode the string anyway.
|
||||
token_bytes = prefix + base64.b64encode(random_bits, altchars=b'xy').strip(b'=')
|
||||
token = token_bytes.decode('ascii')
|
||||
token = prefix + base64.b64encode(random_bits, altchars=b'xy').strip(b'=')
|
||||
|
||||
token_expiry = utcnow() + datetime.timedelta(days=days)
|
||||
token_data = store_token(user_id, token, token_expiry)
|
||||
|
||||
# Include the token in the returned document so that it can be stored client-side,
|
||||
# in configuration, etc.
|
||||
token_data['token'] = token
|
||||
|
||||
return token_data
|
||||
return store_token(user_id, token.decode('ascii'), token_expiry)
|
||||
|
||||
|
||||
def hash_password(password: str, salt: typing.Union[str, bytes]) -> str:
|
||||
|
@@ -12,7 +12,7 @@ ATTACHMENT_SLUG_REGEX = r'[a-zA-Z0-9_\-]+'
|
||||
attachments_embedded_schema = {
|
||||
'type': 'dict',
|
||||
# TODO: will be renamed to 'keyschema' in Cerberus 1.0
|
||||
'propertyschema': {
|
||||
'keyschema': {
|
||||
'type': 'string',
|
||||
'regex': '^%s$' % ATTACHMENT_SLUG_REGEX,
|
||||
},
|
||||
|
@@ -7,7 +7,7 @@ node_type_comment = {
|
||||
'type': 'string',
|
||||
'minlength': 5,
|
||||
'required': True,
|
||||
'coerce': 'markdown',
|
||||
'validator': 'markdown',
|
||||
},
|
||||
'_content_html': {'type': 'string'},
|
||||
'status': {
|
||||
|
@@ -3,7 +3,7 @@ node_type_group = {
|
||||
'description': 'Folder node type',
|
||||
'parent': ['group', 'project'],
|
||||
'dyn_schema': {
|
||||
# Used for sorting within the context of a group
|
||||
|
||||
'order': {
|
||||
'type': 'integer'
|
||||
},
|
||||
@@ -20,7 +20,8 @@ node_type_group = {
|
||||
'notes': {
|
||||
'type': 'string',
|
||||
'maxlength': 256,
|
||||
},
|
||||
}
|
||||
|
||||
},
|
||||
'form_schema': {
|
||||
'url': {'visible': False},
|
||||
|
@@ -9,7 +9,7 @@ node_type_post = {
|
||||
'minlength': 5,
|
||||
'maxlength': 90000,
|
||||
'required': True,
|
||||
'coerce': 'markdown',
|
||||
'validator': 'markdown',
|
||||
},
|
||||
'_content_html': {'type': 'string'},
|
||||
'status': {
|
||||
|
@@ -1,16 +1,12 @@
|
||||
import base64
|
||||
import functools
|
||||
import logging
|
||||
import urllib.parse
|
||||
|
||||
import pymongo.errors
|
||||
import werkzeug.exceptions as wz_exceptions
|
||||
from bson import ObjectId
|
||||
from flask import current_app, Blueprint, request
|
||||
|
||||
from pillar.api.activities import activity_subscribe, activity_object_add
|
||||
from pillar.api.node_types import PILLAR_NAMED_NODE_TYPES
|
||||
from pillar.api.file_storage_backends.gcs import update_file_name
|
||||
from pillar.api.nodes import hooks
|
||||
from pillar.api.nodes.hooks import short_link_info
|
||||
from pillar.api.utils import str2id, jsonify
|
||||
from pillar.api.utils.authorization import check_permissions, require_login
|
||||
|
||||
@@ -19,40 +15,6 @@ blueprint = Blueprint('nodes_api', __name__)
|
||||
ROLES_FOR_SHARING = {'subscriber', 'demo'}
|
||||
|
||||
|
||||
def only_for_node_type_decorator(*required_node_type_names):
|
||||
"""Returns a decorator that checks its first argument's node type.
|
||||
|
||||
If the node type is not of the required node type, returns None,
|
||||
otherwise calls the wrapped function.
|
||||
|
||||
>>> deco = only_for_node_type_decorator('comment')
|
||||
>>> @deco
|
||||
... def handle_comment(node): pass
|
||||
|
||||
>>> deco = only_for_node_type_decorator('comment', 'post')
|
||||
>>> @deco
|
||||
... def handle_comment_or_post(node): pass
|
||||
|
||||
"""
|
||||
|
||||
# Convert to a set for efficient 'x in required_node_type_names' queries.
|
||||
required_node_type_names = set(required_node_type_names)
|
||||
|
||||
def only_for_node_type(wrapped):
|
||||
@functools.wraps(wrapped)
|
||||
def wrapper(node, *args, **kwargs):
|
||||
if node.get('node_type') not in required_node_type_names:
|
||||
return
|
||||
|
||||
return wrapped(node, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
only_for_node_type.__doc__ = "Decorator, immediately returns when " \
|
||||
"the first argument is not of type %s." % required_node_type_names
|
||||
return only_for_node_type
|
||||
|
||||
|
||||
@blueprint.route('/<node_id>/share', methods=['GET', 'POST'])
|
||||
@require_login(require_roles=ROLES_FOR_SHARING)
|
||||
def share_node(node_id):
|
||||
@@ -88,6 +50,67 @@ def share_node(node_id):
|
||||
return jsonify(short_link_info(short_code), status=status)
|
||||
|
||||
|
||||
@blueprint.route('/tagged/')
|
||||
@blueprint.route('/tagged/<tag>')
|
||||
def tagged(tag=''):
|
||||
"""Return all tagged nodes of public projects as JSON."""
|
||||
from pillar.auth import current_user
|
||||
|
||||
# We explicitly register the tagless endpoint to raise a 404, otherwise the PATCH
|
||||
# handler on /api/nodes/<node_id> will return a 405 Method Not Allowed.
|
||||
if not tag:
|
||||
raise wz_exceptions.NotFound()
|
||||
|
||||
# Build the (cached) list of tagged nodes
|
||||
agg_list = _tagged(tag)
|
||||
|
||||
# If the user is anonymous, no more information is needed and we return
|
||||
if current_user.is_anonymous:
|
||||
return jsonify(agg_list)
|
||||
|
||||
# If the user is authenticated, attach view_progress for video assets
|
||||
view_progress = current_user.nodes['view_progress']
|
||||
for node in agg_list:
|
||||
node_id = str(node['_id'])
|
||||
# View progress should be added only for nodes of type 'asset' and
|
||||
# with content_type 'video', only if the video was already in the watched
|
||||
# list for the current user.
|
||||
if node_id in view_progress:
|
||||
node['view_progress'] = view_progress[node_id]
|
||||
|
||||
return jsonify(agg_list)
|
||||
|
||||
|
||||
def _tagged(tag: str):
|
||||
"""Fetch all public nodes with the given tag.
|
||||
|
||||
This function is cached, see setup_app().
|
||||
"""
|
||||
nodes_coll = current_app.db('nodes')
|
||||
agg = nodes_coll.aggregate([
|
||||
{'$match': {'properties.tags': tag,
|
||||
'_deleted': {'$ne': True}}},
|
||||
|
||||
# Only get nodes from public projects. This is done after matching the
|
||||
# tagged nodes, because most likely nobody else will be able to tag
|
||||
# nodes anyway.
|
||||
{'$lookup': {
|
||||
'from': 'projects',
|
||||
'localField': 'project',
|
||||
'foreignField': '_id',
|
||||
'as': '_project',
|
||||
}},
|
||||
{'$match': {'_project.is_private': False}},
|
||||
|
||||
# Don't return the entire project for each node.
|
||||
{'$project': {'_project': False}},
|
||||
|
||||
{'$sort': {'_created': -1}}
|
||||
])
|
||||
|
||||
return list(agg)
|
||||
|
||||
|
||||
def generate_and_store_short_code(node):
|
||||
nodes_coll = current_app.data.driver.db['nodes']
|
||||
node_id = node['_id']
|
||||
@@ -163,265 +186,35 @@ def create_short_code(node) -> str:
|
||||
return short_code
|
||||
|
||||
|
||||
def short_link_info(short_code):
|
||||
"""Returns the short link info in a dict."""
|
||||
|
||||
short_link = urllib.parse.urljoin(
|
||||
current_app.config['SHORT_LINK_BASE_URL'], short_code)
|
||||
|
||||
return {
|
||||
'short_code': short_code,
|
||||
'short_link': short_link,
|
||||
}
|
||||
|
||||
|
||||
def before_replacing_node(item, original):
|
||||
check_permissions('nodes', original, 'PUT')
|
||||
update_file_name(item)
|
||||
|
||||
|
||||
def after_replacing_node(item, original):
|
||||
"""Push an update to the Algolia index when a node item is updated. If the
|
||||
project is private, prevent public indexing.
|
||||
"""
|
||||
|
||||
from pillar.celery import search_index_tasks as index
|
||||
|
||||
projects_collection = current_app.data.driver.db['projects']
|
||||
project = projects_collection.find_one({'_id': item['project']})
|
||||
if project.get('is_private', False):
|
||||
# Skip index updating and return
|
||||
return
|
||||
|
||||
status = item['properties'].get('status', 'unpublished')
|
||||
node_id = str(item['_id'])
|
||||
|
||||
if status == 'published':
|
||||
index.node_save.delay(node_id)
|
||||
else:
|
||||
index.node_delete.delay(node_id)
|
||||
|
||||
|
||||
def before_inserting_nodes(items):
|
||||
"""Before inserting a node in the collection we check if the user is allowed
|
||||
and we append the project id to it.
|
||||
"""
|
||||
from pillar.auth import current_user
|
||||
|
||||
nodes_collection = current_app.data.driver.db['nodes']
|
||||
|
||||
def find_parent_project(node):
|
||||
"""Recursive function that finds the ultimate parent of a node."""
|
||||
if node and 'parent' in node:
|
||||
parent = nodes_collection.find_one({'_id': node['parent']})
|
||||
return find_parent_project(parent)
|
||||
if node:
|
||||
return node
|
||||
else:
|
||||
return None
|
||||
|
||||
for item in items:
|
||||
check_permissions('nodes', item, 'POST')
|
||||
if 'parent' in item and 'project' not in item:
|
||||
parent = nodes_collection.find_one({'_id': item['parent']})
|
||||
project = find_parent_project(parent)
|
||||
if project:
|
||||
item['project'] = project['_id']
|
||||
|
||||
# Default the 'user' property to the current user.
|
||||
item.setdefault('user', current_user.user_id)
|
||||
|
||||
|
||||
def after_inserting_nodes(items):
|
||||
for item in items:
|
||||
# Skip subscriptions for first level items (since the context is not a
|
||||
# node, but a project).
|
||||
# TODO: support should be added for mixed context
|
||||
if 'parent' not in item:
|
||||
return
|
||||
context_object_id = item['parent']
|
||||
if item['node_type'] == 'comment':
|
||||
nodes_collection = current_app.data.driver.db['nodes']
|
||||
parent = nodes_collection.find_one({'_id': item['parent']})
|
||||
# Always subscribe to the parent node
|
||||
activity_subscribe(item['user'], 'node', item['parent'])
|
||||
if parent['node_type'] == 'comment':
|
||||
# If the parent is a comment, we provide its own parent as
|
||||
# context. We do this in order to point the user to an asset
|
||||
# or group when viewing the notification.
|
||||
verb = 'replied'
|
||||
context_object_id = parent['parent']
|
||||
# Subscribe to the parent of the parent comment (post or group)
|
||||
activity_subscribe(item['user'], 'node', parent['parent'])
|
||||
else:
|
||||
activity_subscribe(item['user'], 'node', item['_id'])
|
||||
verb = 'commented'
|
||||
elif item['node_type'] in PILLAR_NAMED_NODE_TYPES:
|
||||
verb = 'posted'
|
||||
activity_subscribe(item['user'], 'node', item['_id'])
|
||||
else:
|
||||
# Don't automatically create activities for non-Pillar node types,
|
||||
# as we don't know what would be a suitable verb (among other things).
|
||||
continue
|
||||
|
||||
activity_object_add(
|
||||
item['user'],
|
||||
verb,
|
||||
'node',
|
||||
item['_id'],
|
||||
'node',
|
||||
context_object_id
|
||||
)
|
||||
|
||||
|
||||
def deduct_content_type(node_doc, original=None):
|
||||
"""Deduct the content type from the attached file, if any."""
|
||||
|
||||
if node_doc['node_type'] != 'asset':
|
||||
log.debug('deduct_content_type: called on node type %r, ignoring', node_doc['node_type'])
|
||||
return
|
||||
|
||||
node_id = node_doc.get('_id')
|
||||
try:
|
||||
file_id = ObjectId(node_doc['properties']['file'])
|
||||
except KeyError:
|
||||
if node_id is None:
|
||||
# Creation of a file-less node is allowed, but updates aren't.
|
||||
return
|
||||
log.warning('deduct_content_type: Asset without properties.file, rejecting.')
|
||||
raise wz_exceptions.UnprocessableEntity('Missing file property for asset node')
|
||||
|
||||
files = current_app.data.driver.db['files']
|
||||
file_doc = files.find_one({'_id': file_id},
|
||||
{'content_type': 1})
|
||||
if not file_doc:
|
||||
log.warning('deduct_content_type: Node %s refers to non-existing file %s, rejecting.',
|
||||
node_id, file_id)
|
||||
raise wz_exceptions.UnprocessableEntity('File property refers to non-existing file')
|
||||
|
||||
# Guess the node content type from the file content type
|
||||
file_type = file_doc['content_type']
|
||||
if file_type.startswith('video/'):
|
||||
content_type = 'video'
|
||||
elif file_type.startswith('image/'):
|
||||
content_type = 'image'
|
||||
else:
|
||||
content_type = 'file'
|
||||
|
||||
node_doc['properties']['content_type'] = content_type
|
||||
|
||||
|
||||
def nodes_deduct_content_type(nodes):
|
||||
for node in nodes:
|
||||
deduct_content_type(node)
|
||||
|
||||
|
||||
def before_returning_node(node):
|
||||
# Run validation process, since GET on nodes entry point is public
|
||||
check_permissions('nodes', node, 'GET', append_allowed_methods=True)
|
||||
|
||||
# Embed short_link_info if the node has a short_code.
|
||||
short_code = node.get('short_code')
|
||||
if short_code:
|
||||
node['short_link'] = short_link_info(short_code)['short_link']
|
||||
|
||||
|
||||
def before_returning_nodes(nodes):
|
||||
for node in nodes['_items']:
|
||||
before_returning_node(node)
|
||||
|
||||
|
||||
def node_set_default_picture(node, original=None):
|
||||
"""Uses the image of an image asset or colour map of texture node as picture."""
|
||||
|
||||
if node.get('picture'):
|
||||
log.debug('Node %s already has a picture, not overriding', node.get('_id'))
|
||||
return
|
||||
|
||||
node_type = node.get('node_type')
|
||||
props = node.get('properties', {})
|
||||
content = props.get('content_type')
|
||||
|
||||
if node_type == 'asset' and content == 'image':
|
||||
image_file_id = props.get('file')
|
||||
elif node_type == 'texture':
|
||||
# Find the colour map, defaulting to the first image map available.
|
||||
image_file_id = None
|
||||
for image in props.get('files', []):
|
||||
if image_file_id is None or image.get('map_type') == 'color':
|
||||
image_file_id = image.get('file')
|
||||
else:
|
||||
log.debug('Not setting default picture on node type %s content type %s',
|
||||
node_type, content)
|
||||
return
|
||||
|
||||
if image_file_id is None:
|
||||
log.debug('Nothing to set the picture to.')
|
||||
return
|
||||
|
||||
log.debug('Setting default picture for node %s to %s', node.get('_id'), image_file_id)
|
||||
node['picture'] = image_file_id
|
||||
|
||||
|
||||
def nodes_set_default_picture(nodes):
|
||||
for node in nodes:
|
||||
node_set_default_picture(node)
|
||||
|
||||
|
||||
def before_deleting_node(node: dict):
|
||||
check_permissions('nodes', node, 'DELETE')
|
||||
|
||||
|
||||
def after_deleting_node(item):
|
||||
from pillar.celery import search_index_tasks as index
|
||||
index.node_delete.delay(str(item['_id']))
|
||||
|
||||
|
||||
only_for_textures = only_for_node_type_decorator('texture')
|
||||
|
||||
|
||||
@only_for_textures
|
||||
def texture_sort_files(node, original=None):
|
||||
"""Sort files alphabetically by map type, with colour map first."""
|
||||
|
||||
try:
|
||||
files = node['properties']['files']
|
||||
except KeyError:
|
||||
return
|
||||
|
||||
# Sort the map types alphabetically, ensuring 'color' comes first.
|
||||
as_dict = {f['map_type']: f for f in files}
|
||||
types = sorted(as_dict.keys(), key=lambda k: '\0' if k == 'color' else k)
|
||||
node['properties']['files'] = [as_dict[map_type] for map_type in types]
|
||||
|
||||
|
||||
def textures_sort_files(nodes):
|
||||
for node in nodes:
|
||||
texture_sort_files(node)
|
||||
|
||||
|
||||
def setup_app(app, url_prefix):
|
||||
global _tagged
|
||||
|
||||
cached = app.cache.memoize(timeout=300)
|
||||
_tagged = cached(_tagged)
|
||||
|
||||
from . import patch
|
||||
patch.setup_app(app, url_prefix=url_prefix)
|
||||
|
||||
app.on_fetched_item_nodes += before_returning_node
|
||||
app.on_fetched_resource_nodes += before_returning_nodes
|
||||
app.on_fetched_item_nodes += hooks.before_returning_node
|
||||
app.on_fetched_resource_nodes += hooks.before_returning_nodes
|
||||
|
||||
app.on_replace_nodes += before_replacing_node
|
||||
app.on_replace_nodes += texture_sort_files
|
||||
app.on_replace_nodes += deduct_content_type
|
||||
app.on_replace_nodes += node_set_default_picture
|
||||
app.on_replaced_nodes += after_replacing_node
|
||||
app.on_replace_nodes += hooks.before_replacing_node
|
||||
app.on_replace_nodes += hooks.parse_markdown
|
||||
app.on_replace_nodes += hooks.texture_sort_files
|
||||
app.on_replace_nodes += hooks.deduct_content_type
|
||||
app.on_replace_nodes += hooks.node_set_default_picture
|
||||
app.on_replaced_nodes += hooks.after_replacing_node
|
||||
|
||||
app.on_insert_nodes += before_inserting_nodes
|
||||
app.on_insert_nodes += nodes_deduct_content_type
|
||||
app.on_insert_nodes += nodes_set_default_picture
|
||||
app.on_insert_nodes += textures_sort_files
|
||||
app.on_inserted_nodes += after_inserting_nodes
|
||||
app.on_insert_nodes += hooks.before_inserting_nodes
|
||||
app.on_insert_nodes += hooks.parse_markdowns
|
||||
app.on_insert_nodes += hooks.nodes_deduct_content_type
|
||||
app.on_insert_nodes += hooks.nodes_set_default_picture
|
||||
app.on_insert_nodes += hooks.textures_sort_files
|
||||
app.on_inserted_nodes += hooks.after_inserting_nodes
|
||||
|
||||
app.on_update_nodes += texture_sort_files
|
||||
app.on_update_nodes += hooks.texture_sort_files
|
||||
|
||||
app.on_delete_item_nodes += before_deleting_node
|
||||
app.on_deleted_item_nodes += after_deleting_node
|
||||
app.on_delete_item_nodes += hooks.before_deleting_node
|
||||
app.on_deleted_item_nodes += hooks.after_deleting_node
|
||||
|
||||
app.register_api_blueprint(blueprint, url_prefix=url_prefix)
|
||||
|
325
pillar/api/nodes/hooks.py
Normal file
325
pillar/api/nodes/hooks.py
Normal file
@@ -0,0 +1,325 @@
|
||||
import functools
|
||||
import logging
|
||||
import urllib.parse
|
||||
from bson import ObjectId
|
||||
from flask import current_app
|
||||
from werkzeug import exceptions as wz_exceptions
|
||||
|
||||
import pillar.markdown
|
||||
from pillar.api.activities import activity_subscribe, activity_object_add
|
||||
from pillar.api.file_storage_backends.gcs import update_file_name
|
||||
from pillar.api.node_types import PILLAR_NAMED_NODE_TYPES
|
||||
from pillar.api.utils.authorization import check_permissions
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def before_returning_node(node):
|
||||
# Run validation process, since GET on nodes entry point is public
|
||||
check_permissions('nodes', node, 'GET', append_allowed_methods=True)
|
||||
|
||||
# Embed short_link_info if the node has a short_code.
|
||||
short_code = node.get('short_code')
|
||||
if short_code:
|
||||
node['short_link'] = short_link_info(short_code)['short_link']
|
||||
|
||||
|
||||
def before_returning_nodes(nodes):
|
||||
for node in nodes['_items']:
|
||||
before_returning_node(node)
|
||||
|
||||
|
||||
def only_for_node_type_decorator(*required_node_type_names):
|
||||
"""Returns a decorator that checks its first argument's node type.
|
||||
|
||||
If the node type is not of the required node type, returns None,
|
||||
otherwise calls the wrapped function.
|
||||
|
||||
>>> deco = only_for_node_type_decorator('comment')
|
||||
>>> @deco
|
||||
... def handle_comment(node): pass
|
||||
|
||||
>>> deco = only_for_node_type_decorator('comment', 'post')
|
||||
>>> @deco
|
||||
... def handle_comment_or_post(node): pass
|
||||
|
||||
"""
|
||||
|
||||
# Convert to a set for efficient 'x in required_node_type_names' queries.
|
||||
required_node_type_names = set(required_node_type_names)
|
||||
|
||||
def only_for_node_type(wrapped):
|
||||
@functools.wraps(wrapped)
|
||||
def wrapper(node, *args, **kwargs):
|
||||
if node.get('node_type') not in required_node_type_names:
|
||||
return
|
||||
|
||||
return wrapped(node, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
only_for_node_type.__doc__ = "Decorator, immediately returns when " \
|
||||
"the first argument is not of type %s." % required_node_type_names
|
||||
return only_for_node_type
|
||||
|
||||
|
||||
def before_replacing_node(item, original):
|
||||
check_permissions('nodes', original, 'PUT')
|
||||
update_file_name(item)
|
||||
|
||||
|
||||
def after_replacing_node(item, original):
|
||||
"""Push an update to the Algolia index when a node item is updated. If the
|
||||
project is private, prevent public indexing.
|
||||
"""
|
||||
|
||||
from pillar.celery import search_index_tasks as index
|
||||
|
||||
projects_collection = current_app.data.driver.db['projects']
|
||||
project = projects_collection.find_one({'_id': item['project']})
|
||||
if project.get('is_private', False):
|
||||
# Skip index updating and return
|
||||
return
|
||||
|
||||
status = item['properties'].get('status', 'unpublished')
|
||||
node_id = str(item['_id'])
|
||||
|
||||
if status == 'published':
|
||||
index.node_save.delay(node_id)
|
||||
else:
|
||||
index.node_delete.delay(node_id)
|
||||
|
||||
|
||||
def before_inserting_nodes(items):
|
||||
"""Before inserting a node in the collection we check if the user is allowed
|
||||
and we append the project id to it.
|
||||
"""
|
||||
from pillar.auth import current_user
|
||||
|
||||
nodes_collection = current_app.data.driver.db['nodes']
|
||||
|
||||
def find_parent_project(node):
|
||||
"""Recursive function that finds the ultimate parent of a node."""
|
||||
if node and 'parent' in node:
|
||||
parent = nodes_collection.find_one({'_id': node['parent']})
|
||||
return find_parent_project(parent)
|
||||
if node:
|
||||
return node
|
||||
else:
|
||||
return None
|
||||
|
||||
for item in items:
|
||||
check_permissions('nodes', item, 'POST')
|
||||
if 'parent' in item and 'project' not in item:
|
||||
parent = nodes_collection.find_one({'_id': item['parent']})
|
||||
project = find_parent_project(parent)
|
||||
if project:
|
||||
item['project'] = project['_id']
|
||||
|
||||
# Default the 'user' property to the current user.
|
||||
item.setdefault('user', current_user.user_id)
|
||||
|
||||
|
||||
def after_inserting_nodes(items):
|
||||
for item in items:
|
||||
# Skip subscriptions for first level items (since the context is not a
|
||||
# node, but a project).
|
||||
# TODO: support should be added for mixed context
|
||||
if 'parent' not in item:
|
||||
return
|
||||
context_object_id = item['parent']
|
||||
if item['node_type'] == 'comment':
|
||||
nodes_collection = current_app.data.driver.db['nodes']
|
||||
parent = nodes_collection.find_one({'_id': item['parent']})
|
||||
# Always subscribe to the parent node
|
||||
activity_subscribe(item['user'], 'node', item['parent'])
|
||||
if parent['node_type'] == 'comment':
|
||||
# If the parent is a comment, we provide its own parent as
|
||||
# context. We do this in order to point the user to an asset
|
||||
# or group when viewing the notification.
|
||||
verb = 'replied'
|
||||
context_object_id = parent['parent']
|
||||
# Subscribe to the parent of the parent comment (post or group)
|
||||
activity_subscribe(item['user'], 'node', parent['parent'])
|
||||
else:
|
||||
activity_subscribe(item['user'], 'node', item['_id'])
|
||||
verb = 'commented'
|
||||
elif item['node_type'] in PILLAR_NAMED_NODE_TYPES:
|
||||
verb = 'posted'
|
||||
activity_subscribe(item['user'], 'node', item['_id'])
|
||||
else:
|
||||
# Don't automatically create activities for non-Pillar node types,
|
||||
# as we don't know what would be a suitable verb (among other things).
|
||||
continue
|
||||
|
||||
activity_object_add(
|
||||
item['user'],
|
||||
verb,
|
||||
'node',
|
||||
item['_id'],
|
||||
'node',
|
||||
context_object_id
|
||||
)
|
||||
|
||||
|
||||
def deduct_content_type(node_doc, original=None):
|
||||
"""Deduct the content type from the attached file, if any."""
|
||||
|
||||
if node_doc['node_type'] != 'asset':
|
||||
log.debug('deduct_content_type: called on node type %r, ignoring', node_doc['node_type'])
|
||||
return
|
||||
|
||||
node_id = node_doc.get('_id')
|
||||
try:
|
||||
file_id = ObjectId(node_doc['properties']['file'])
|
||||
except KeyError:
|
||||
if node_id is None:
|
||||
# Creation of a file-less node is allowed, but updates aren't.
|
||||
return
|
||||
log.warning('deduct_content_type: Asset without properties.file, rejecting.')
|
||||
raise wz_exceptions.UnprocessableEntity('Missing file property for asset node')
|
||||
|
||||
files = current_app.data.driver.db['files']
|
||||
file_doc = files.find_one({'_id': file_id},
|
||||
{'content_type': 1})
|
||||
if not file_doc:
|
||||
log.warning('deduct_content_type: Node %s refers to non-existing file %s, rejecting.',
|
||||
node_id, file_id)
|
||||
raise wz_exceptions.UnprocessableEntity('File property refers to non-existing file')
|
||||
|
||||
# Guess the node content type from the file content type
|
||||
file_type = file_doc['content_type']
|
||||
if file_type.startswith('video/'):
|
||||
content_type = 'video'
|
||||
elif file_type.startswith('image/'):
|
||||
content_type = 'image'
|
||||
else:
|
||||
content_type = 'file'
|
||||
|
||||
node_doc['properties']['content_type'] = content_type
|
||||
|
||||
|
||||
def nodes_deduct_content_type(nodes):
|
||||
for node in nodes:
|
||||
deduct_content_type(node)
|
||||
|
||||
|
||||
def node_set_default_picture(node, original=None):
|
||||
"""Uses the image of an image asset or colour map of texture node as picture."""
|
||||
|
||||
if node.get('picture'):
|
||||
log.debug('Node %s already has a picture, not overriding', node.get('_id'))
|
||||
return
|
||||
|
||||
node_type = node.get('node_type')
|
||||
props = node.get('properties', {})
|
||||
content = props.get('content_type')
|
||||
|
||||
if node_type == 'asset' and content == 'image':
|
||||
image_file_id = props.get('file')
|
||||
elif node_type == 'texture':
|
||||
# Find the colour map, defaulting to the first image map available.
|
||||
image_file_id = None
|
||||
for image in props.get('files', []):
|
||||
if image_file_id is None or image.get('map_type') == 'color':
|
||||
image_file_id = image.get('file')
|
||||
else:
|
||||
log.debug('Not setting default picture on node type %s content type %s',
|
||||
node_type, content)
|
||||
return
|
||||
|
||||
if image_file_id is None:
|
||||
log.debug('Nothing to set the picture to.')
|
||||
return
|
||||
|
||||
log.debug('Setting default picture for node %s to %s', node.get('_id'), image_file_id)
|
||||
node['picture'] = image_file_id
|
||||
|
||||
|
||||
def nodes_set_default_picture(nodes):
|
||||
for node in nodes:
|
||||
node_set_default_picture(node)
|
||||
|
||||
|
||||
def before_deleting_node(node: dict):
|
||||
check_permissions('nodes', node, 'DELETE')
|
||||
|
||||
|
||||
def after_deleting_node(item):
|
||||
from pillar.celery import search_index_tasks as index
|
||||
index.node_delete.delay(str(item['_id']))
|
||||
|
||||
|
||||
only_for_textures = only_for_node_type_decorator('texture')
|
||||
|
||||
|
||||
@only_for_textures
|
||||
def texture_sort_files(node, original=None):
|
||||
"""Sort files alphabetically by map type, with colour map first."""
|
||||
|
||||
try:
|
||||
files = node['properties']['files']
|
||||
except KeyError:
|
||||
return
|
||||
|
||||
# Sort the map types alphabetically, ensuring 'color' comes first.
|
||||
as_dict = {f['map_type']: f for f in files}
|
||||
types = sorted(as_dict.keys(), key=lambda k: '\0' if k == 'color' else k)
|
||||
node['properties']['files'] = [as_dict[map_type] for map_type in types]
|
||||
|
||||
|
||||
def textures_sort_files(nodes):
|
||||
for node in nodes:
|
||||
texture_sort_files(node)
|
||||
|
||||
|
||||
def parse_markdown(node, original=None):
|
||||
import copy
|
||||
|
||||
projects_collection = current_app.data.driver.db['projects']
|
||||
project = projects_collection.find_one({'_id': node['project']}, {'node_types': 1})
|
||||
# Query node type directly using the key
|
||||
node_type = next(nt for nt in project['node_types']
|
||||
if nt['name'] == node['node_type'])
|
||||
|
||||
# Create a copy to not overwrite the actual schema.
|
||||
schema = copy.deepcopy(current_app.config['DOMAIN']['nodes']['schema'])
|
||||
schema['properties'] = node_type['dyn_schema']
|
||||
|
||||
def find_markdown_fields(schema, node):
|
||||
"""Find and process all makrdown validated fields."""
|
||||
for k, v in schema.items():
|
||||
if not isinstance(v, dict):
|
||||
continue
|
||||
|
||||
if v.get('validator') == 'markdown':
|
||||
# If there is a match with the validator: markdown pair, assign the sibling
|
||||
# property (following the naming convention _<property>_html)
|
||||
# the processed value.
|
||||
if k in node:
|
||||
html = pillar.markdown.markdown(node[k])
|
||||
field_name = pillar.markdown.cache_field_name(k)
|
||||
node[field_name] = html
|
||||
if isinstance(node, dict) and k in node:
|
||||
find_markdown_fields(v, node[k])
|
||||
|
||||
find_markdown_fields(schema, node)
|
||||
|
||||
return 'ok'
|
||||
|
||||
|
||||
def parse_markdowns(items):
|
||||
for item in items:
|
||||
parse_markdown(item)
|
||||
|
||||
|
||||
def short_link_info(short_code):
|
||||
"""Returns the short link info in a dict."""
|
||||
|
||||
short_link = urllib.parse.urljoin(
|
||||
current_app.config['SHORT_LINK_BASE_URL'], short_code)
|
||||
|
||||
return {
|
||||
'short_code': short_code,
|
||||
'short_link': short_link,
|
||||
}
|
@@ -1,7 +1,7 @@
|
||||
"""Code for moving around nodes."""
|
||||
|
||||
import attr
|
||||
import flask_pymongo.wrappers
|
||||
import pymongo.database
|
||||
from bson import ObjectId
|
||||
|
||||
from pillar import attrs_extra
|
||||
@@ -10,7 +10,7 @@ import pillar.api.file_storage.moving
|
||||
|
||||
@attr.s
|
||||
class NodeMover(object):
|
||||
db = attr.ib(validator=attr.validators.instance_of(flask_pymongo.wrappers.Database))
|
||||
db = attr.ib(validator=attr.validators.instance_of(pymongo.database.Database))
|
||||
skip_gcs = attr.ib(default=False, validator=attr.validators.instance_of(bool))
|
||||
_log = attrs_extra.log('%s.NodeMover' % __name__)
|
||||
|
||||
|
@@ -71,14 +71,19 @@ def before_delete_project(document):
|
||||
|
||||
def after_delete_project(project: dict):
|
||||
"""Perform delete on the project's files too."""
|
||||
|
||||
from werkzeug.exceptions import NotFound
|
||||
from eve.methods.delete import delete
|
||||
|
||||
pid = project['_id']
|
||||
log.info('Project %s was deleted, also deleting its files.', pid)
|
||||
|
||||
r, _, _, status = delete('files', {'project': pid})
|
||||
try:
|
||||
r, _, _, status = delete('files', {'project': pid})
|
||||
except NotFound:
|
||||
# There were no files, and that's fine.
|
||||
return
|
||||
if status != 204:
|
||||
# Will never happen because bloody Eve always returns 204 or raises an exception.
|
||||
log.warning('Unable to delete files of project %s: %s', pid, r)
|
||||
|
||||
|
||||
|
@@ -142,7 +142,7 @@ def after_fetching_user(user):
|
||||
return
|
||||
|
||||
# Remove all fields except public ones.
|
||||
public_fields = {'full_name', 'username', 'email', 'extension_props_public'}
|
||||
public_fields = {'full_name', 'username', 'email', 'extension_props_public', 'badges'}
|
||||
for field in list(user.keys()):
|
||||
if field not in public_fields:
|
||||
del user[field]
|
||||
|
@@ -1,9 +1,11 @@
|
||||
import logging
|
||||
|
||||
from eve.methods.get import get
|
||||
from flask import Blueprint
|
||||
from flask import Blueprint, request
|
||||
import werkzeug.exceptions as wz_exceptions
|
||||
|
||||
from pillar.api.utils import jsonify
|
||||
from pillar import current_app
|
||||
from pillar.api import utils
|
||||
from pillar.api.utils.authorization import require_login
|
||||
from pillar.auth import current_user
|
||||
|
||||
@@ -15,7 +17,128 @@ blueprint_api = Blueprint('users_api', __name__)
|
||||
@require_login()
|
||||
def my_info():
|
||||
eve_resp, _, _, status, _ = get('users', {'_id': current_user.user_id})
|
||||
resp = jsonify(eve_resp['_items'][0], status=status)
|
||||
resp = utils.jsonify(eve_resp['_items'][0], status=status)
|
||||
return resp
|
||||
|
||||
|
||||
@blueprint_api.route('/video/<video_id>/progress')
|
||||
@require_login()
|
||||
def get_video_progress(video_id: str):
|
||||
"""Return video progress information.
|
||||
|
||||
Either a `204 No Content` is returned (no information stored),
|
||||
or a `200 Ok` with JSON from Eve's 'users' schema, from the key
|
||||
video.view_progress.<video_id>.
|
||||
"""
|
||||
|
||||
# Validation of the video ID; raises a BadRequest when it's not an ObjectID.
|
||||
# This isn't strictly necessary, but it makes this function behave symmetrical
|
||||
# to the set_video_progress() function.
|
||||
utils.str2id(video_id)
|
||||
|
||||
users_coll = current_app.db('users')
|
||||
user_doc = users_coll.find_one(current_user.user_id, projection={'nodes.view_progress': True})
|
||||
try:
|
||||
progress = user_doc['nodes']['view_progress'][video_id]
|
||||
except KeyError:
|
||||
return '', 204
|
||||
if not progress:
|
||||
return '', 204
|
||||
|
||||
return utils.jsonify(progress)
|
||||
|
||||
|
||||
@blueprint_api.route('/video/<video_id>/progress', methods=['POST'])
|
||||
@require_login()
|
||||
def set_video_progress(video_id: str):
|
||||
"""Save progress information about a certain video.
|
||||
|
||||
Expected parameters:
|
||||
- progress_in_sec: float number of seconds
|
||||
- progress_in_perc: integer percentage of video watched (interval [0-100])
|
||||
"""
|
||||
my_log = log.getChild('set_video_progress')
|
||||
my_log.debug('Setting video progress for user %r video %r', current_user.user_id, video_id)
|
||||
|
||||
# Constructing this response requires an active app, and thus can't be done on module load.
|
||||
no_video_response = utils.jsonify({'_message': 'No such video'}, status=404)
|
||||
|
||||
try:
|
||||
progress_in_sec = float(request.form['progress_in_sec'])
|
||||
progress_in_perc = int(request.form['progress_in_perc'])
|
||||
except KeyError as ex:
|
||||
my_log.debug('Missing POST field in request: %s', ex)
|
||||
raise wz_exceptions.BadRequest(f'missing a form field')
|
||||
except ValueError as ex:
|
||||
my_log.debug('Invalid value for POST field in request: %s', ex)
|
||||
raise wz_exceptions.BadRequest(f'Invalid value for field: {ex}')
|
||||
|
||||
users_coll = current_app.db('users')
|
||||
nodes_coll = current_app.db('nodes')
|
||||
|
||||
# First check whether this is actually an existing video
|
||||
video_oid = utils.str2id(video_id)
|
||||
video_doc = nodes_coll.find_one(video_oid, projection={
|
||||
'node_type': True,
|
||||
'properties.content_type': True,
|
||||
'properties.file': True,
|
||||
})
|
||||
if not video_doc:
|
||||
my_log.debug('Node %r not found, unable to set progress for user %r',
|
||||
video_oid, current_user.user_id)
|
||||
return no_video_response
|
||||
|
||||
try:
|
||||
is_video = (video_doc['node_type'] == 'asset'
|
||||
and video_doc['properties']['content_type'] == 'video')
|
||||
except KeyError:
|
||||
is_video = False
|
||||
|
||||
if not is_video:
|
||||
my_log.info('Node %r is not a video, unable to set progress for user %r',
|
||||
video_oid, current_user.user_id)
|
||||
# There is no video found at this URL, so act as if it doesn't even exist.
|
||||
return no_video_response
|
||||
|
||||
# Compute the progress
|
||||
percent = min(100, max(0, progress_in_perc))
|
||||
progress = {
|
||||
'progress_in_sec': progress_in_sec,
|
||||
'progress_in_percent': percent,
|
||||
'last_watched': utils.utcnow(),
|
||||
}
|
||||
|
||||
# After watching a certain percentage of the video, we consider it 'done'
|
||||
#
|
||||
# Total Credit start Total Credit Percent
|
||||
# HH:MM:SS HH:MM:SS sec sec of duration
|
||||
# Sintel 00:14:48 00:12:24 888 744 83.78%
|
||||
# Tears of Steel 00:12:14 00:09:49 734 589 80.25%
|
||||
# Cosmos Laundro 00:12:10 00:10:05 730 605 82.88%
|
||||
# Agent 327 00:03:51 00:03:26 231 206 89.18%
|
||||
# Caminandes 3 00:02:30 00:02:18 150 138 92.00%
|
||||
# Glass Half 00:03:13 00:02:52 193 172 89.12%
|
||||
# Big Buck Bunny 00:09:56 00:08:11 596 491 82.38%
|
||||
# Elephant’s Drea 00:10:54 00:09:25 654 565 86.39%
|
||||
#
|
||||
# Median 85.09%
|
||||
# Average 85.75%
|
||||
#
|
||||
# For training videos marking at done at 85% of the video may be a bit
|
||||
# early, since those probably won't have (long) credits. This is why we
|
||||
# stick to 90% here.
|
||||
if percent >= 90:
|
||||
progress['done'] = True
|
||||
|
||||
# Setting each property individually prevents us from overwriting any
|
||||
# existing {done: true} fields.
|
||||
updates = {f'nodes.view_progress.{video_id}.{k}': v
|
||||
for k, v in progress.items()}
|
||||
result = users_coll.update_one({'_id': current_user.user_id},
|
||||
{'$set': updates})
|
||||
|
||||
if result.matched_count == 0:
|
||||
my_log.error('Current user %r could not be updated', current_user.user_id)
|
||||
raise wz_exceptions.InternalServerError('Unable to find logged-in user')
|
||||
|
||||
return '', 204
|
||||
|
@@ -245,4 +245,10 @@ def random_etag() -> str:
|
||||
|
||||
|
||||
def utcnow() -> datetime.datetime:
|
||||
return datetime.datetime.now(tz=bson.tz_util.utc)
|
||||
"""Construct timezone-aware 'now' in UTC with millisecond precision."""
|
||||
now = datetime.datetime.now(tz=bson.tz_util.utc)
|
||||
|
||||
# MongoDB stores in millisecond precision, so truncate the microseconds.
|
||||
# This way the returned datetime can be round-tripped via MongoDB and stay the same.
|
||||
trunc_now = now.replace(microsecond=now.microsecond - (now.microsecond % 1000))
|
||||
return trunc_now
|
||||
|
@@ -13,7 +13,7 @@ import logging
|
||||
import typing
|
||||
|
||||
import bson
|
||||
from flask import g, current_app
|
||||
from flask import g, current_app, session
|
||||
from flask import request
|
||||
from werkzeug import exceptions as wz_exceptions
|
||||
|
||||
@@ -103,7 +103,7 @@ def find_user_in_db(user_info: dict, provider='blender-id') -> dict:
|
||||
return db_user
|
||||
|
||||
|
||||
def validate_token(*, force=False):
|
||||
def validate_token(*, force=False) -> bool:
|
||||
"""Validate the token provided in the request and populate the current_user
|
||||
flask.g object, so that permissions and access to a resource can be defined
|
||||
from it.
|
||||
@@ -115,7 +115,7 @@ def validate_token(*, force=False):
|
||||
:returns: True iff the user is logged in with a valid Blender ID token.
|
||||
"""
|
||||
|
||||
from pillar.auth import AnonymousUser
|
||||
import pillar.auth
|
||||
|
||||
# Trust a pre-existing g.current_user
|
||||
if not force:
|
||||
@@ -133,16 +133,22 @@ def validate_token(*, force=False):
|
||||
oauth_subclient = ''
|
||||
else:
|
||||
# Check the session, the user might be logged in through Flask-Login.
|
||||
from pillar import auth
|
||||
|
||||
token = auth.get_blender_id_oauth_token()
|
||||
# The user has a logged-in session; trust only if this request passes a CSRF check.
|
||||
# FIXME(Sybren): we should stop saving the token as 'user_id' in the sesion.
|
||||
token = session.get('user_id')
|
||||
if token:
|
||||
log.debug('skipping token check because current user already has a session')
|
||||
current_app.csrf.protect()
|
||||
else:
|
||||
token = pillar.auth.get_blender_id_oauth_token()
|
||||
oauth_subclient = None
|
||||
|
||||
if not token:
|
||||
# If no authorization headers are provided, we are getting a request
|
||||
# from a non logged in user. Proceed accordingly.
|
||||
log.debug('No authentication headers, so not logged in.')
|
||||
g.current_user = AnonymousUser()
|
||||
g.current_user = pillar.auth.AnonymousUser()
|
||||
return False
|
||||
|
||||
return validate_this_token(token, oauth_subclient) is not None
|
||||
@@ -194,7 +200,7 @@ def remove_token(token: str):
|
||||
tokens_coll = current_app.db('tokens')
|
||||
token_hashed = hash_auth_token(token)
|
||||
|
||||
# TODO: remove matching on unhashed tokens once all tokens have been hashed.
|
||||
# TODO: remove matching on hashed tokens once all hashed tokens have expired.
|
||||
lookup = {'$or': [{'token': token}, {'token_hashed': token_hashed}]}
|
||||
del_res = tokens_coll.delete_many(lookup)
|
||||
log.debug('Removed token %r, matched %d documents', token, del_res.deleted_count)
|
||||
@@ -206,7 +212,7 @@ def find_token(token, is_subclient_token=False, **extra_filters):
|
||||
tokens_coll = current_app.db('tokens')
|
||||
token_hashed = hash_auth_token(token)
|
||||
|
||||
# TODO: remove matching on unhashed tokens once all tokens have been hashed.
|
||||
# TODO: remove matching on hashed tokens once all hashed tokens have expired.
|
||||
lookup = {'$or': [{'token': token}, {'token_hashed': token_hashed}],
|
||||
'is_subclient_token': True if is_subclient_token else {'$in': [False, None]},
|
||||
'expire_time': {"$gt": utcnow()}}
|
||||
@@ -229,8 +235,14 @@ def hash_auth_token(token: str) -> str:
|
||||
return base64.b64encode(digest).decode('ascii')
|
||||
|
||||
|
||||
def store_token(user_id, token: str, token_expiry, oauth_subclient_id=False,
|
||||
org_roles: typing.Set[str] = frozenset()):
|
||||
def store_token(user_id,
|
||||
token: str,
|
||||
token_expiry,
|
||||
oauth_subclient_id=False,
|
||||
*,
|
||||
org_roles: typing.Set[str] = frozenset(),
|
||||
oauth_scopes: typing.Optional[typing.List[str]] = None,
|
||||
):
|
||||
"""Stores an authentication token.
|
||||
|
||||
:returns: the token document from MongoDB
|
||||
@@ -240,13 +252,15 @@ def store_token(user_id, token: str, token_expiry, oauth_subclient_id=False,
|
||||
|
||||
token_data = {
|
||||
'user': user_id,
|
||||
'token_hashed': hash_auth_token(token),
|
||||
'token': token,
|
||||
'expire_time': token_expiry,
|
||||
}
|
||||
if oauth_subclient_id:
|
||||
token_data['is_subclient_token'] = True
|
||||
if org_roles:
|
||||
token_data['org_roles'] = sorted(org_roles)
|
||||
if oauth_scopes:
|
||||
token_data['oauth_scopes'] = oauth_scopes
|
||||
|
||||
r, _, _, status = current_app.post_internal('tokens', token_data)
|
||||
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import functools
|
||||
import typing
|
||||
|
||||
from bson import ObjectId
|
||||
from flask import g
|
||||
@@ -12,8 +13,9 @@ CHECK_PERMISSIONS_IMPLEMENTED_FOR = {'projects', 'nodes', 'flamenco_jobs'}
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def check_permissions(collection_name, resource, method, append_allowed_methods=False,
|
||||
check_node_type=None):
|
||||
def check_permissions(collection_name: str, resource: dict, method: str,
|
||||
append_allowed_methods=False,
|
||||
check_node_type: typing.Optional[str] = None):
|
||||
"""Check user permissions to access a node. We look up node permissions from
|
||||
world to groups to users and match them with the computed user permissions.
|
||||
If there is not match, we raise 403.
|
||||
@@ -93,8 +95,9 @@ def compute_allowed_methods(collection_name, resource, check_node_type=None):
|
||||
return allowed_methods
|
||||
|
||||
|
||||
def has_permissions(collection_name, resource, method, append_allowed_methods=False,
|
||||
check_node_type=None):
|
||||
def has_permissions(collection_name: str, resource: dict, method: str,
|
||||
append_allowed_methods=False,
|
||||
check_node_type: typing.Optional[str] = None):
|
||||
"""Check user permissions to access a node. We look up node permissions from
|
||||
world to groups to users and match them with the computed user permissions.
|
||||
|
||||
|
@@ -38,6 +38,8 @@ class UserClass(flask_login.UserMixin):
|
||||
self.groups: typing.List[str] = [] # NOTE: these are stringified object IDs.
|
||||
self.group_ids: typing.List[bson.ObjectId] = []
|
||||
self.capabilities: typing.Set[str] = set()
|
||||
self.nodes: dict = {} # see the 'nodes' key in eve_settings.py::user_schema.
|
||||
self.badges_html: str = ''
|
||||
|
||||
# Lazily evaluated
|
||||
self._has_organizations: typing.Optional[bool] = None
|
||||
@@ -56,6 +58,12 @@ class UserClass(flask_login.UserMixin):
|
||||
user.email = db_user.get('email') or ''
|
||||
user.username = db_user.get('username') or ''
|
||||
user.full_name = db_user.get('full_name') or ''
|
||||
user.badges_html = db_user.get('badges', {}).get('html') or ''
|
||||
|
||||
# Be a little more specific than just db_user['nodes']
|
||||
user.nodes = {
|
||||
'view_progress': db_user.get('nodes', {}).get('view_progress', {}),
|
||||
}
|
||||
|
||||
# Derived properties
|
||||
user.objectid = str(user.user_id or '')
|
||||
@@ -210,6 +218,11 @@ def login_user(oauth_token: str, *, load_from_db=False):
|
||||
user = _load_user(oauth_token)
|
||||
else:
|
||||
user = UserClass(oauth_token)
|
||||
login_user_object(user)
|
||||
|
||||
|
||||
def login_user_object(user: UserClass):
|
||||
"""Log in the given user."""
|
||||
flask_login.login_user(user, remember=True)
|
||||
g.current_user = user
|
||||
user_authenticated.send(None)
|
||||
|
@@ -1,8 +1,9 @@
|
||||
import abc
|
||||
import attr
|
||||
import json
|
||||
import logging
|
||||
import typing
|
||||
|
||||
import attr
|
||||
from rauth import OAuth2Service
|
||||
from flask import current_app, url_for, request, redirect, session, Response
|
||||
|
||||
@@ -15,6 +16,8 @@ class OAuthUserResponse:
|
||||
|
||||
id = attr.ib(validator=attr.validators.instance_of(str))
|
||||
email = attr.ib(validator=attr.validators.instance_of(str))
|
||||
access_token = attr.ib(validator=attr.validators.instance_of(str))
|
||||
scopes: typing.List[str] = attr.ib(validator=attr.validators.instance_of(list))
|
||||
|
||||
|
||||
class OAuthError(Exception):
|
||||
@@ -127,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
183
pillar/badge_sync.py
Normal file
@@ -0,0 +1,183 @@
|
||||
import collections
|
||||
import datetime
|
||||
import logging
|
||||
import typing
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import bson
|
||||
import requests
|
||||
|
||||
from pillar import current_app
|
||||
from pillar.api.utils import utcnow
|
||||
|
||||
SyncUser = collections.namedtuple('SyncUser', 'user_id token bid_user_id')
|
||||
BadgeHTML = collections.namedtuple('BadgeHTML', 'html expires')
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StopRefreshing(Exception):
|
||||
"""Indicates that Blender ID is having problems.
|
||||
|
||||
Further badge refreshes should be put on hold to avoid bludgeoning
|
||||
a suffering Blender ID.
|
||||
"""
|
||||
|
||||
|
||||
def find_users_to_sync() -> typing.Iterable[SyncUser]:
|
||||
"""Return user information of syncable users with badges."""
|
||||
|
||||
now = utcnow()
|
||||
tokens_coll = current_app.db('tokens')
|
||||
cursor = tokens_coll.aggregate([
|
||||
# Find all users who have a 'badge' scope in their OAuth token.
|
||||
{'$match': {
|
||||
'token': {'$exists': True},
|
||||
'oauth_scopes': 'badge',
|
||||
'expire_time': {'$gt': now},
|
||||
}},
|
||||
{'$lookup': {
|
||||
'from': 'users',
|
||||
'localField': 'user',
|
||||
'foreignField': '_id',
|
||||
'as': 'user'
|
||||
}},
|
||||
|
||||
# Prevent 'user' from being an array.
|
||||
{'$unwind': {'path': '$user'}},
|
||||
|
||||
# Get the Blender ID user ID only.
|
||||
{'$unwind': {'path': '$user.auth'}},
|
||||
{'$match': {'user.auth.provider': 'blender-id'}},
|
||||
|
||||
# Only select those users whose badge doesn't exist or has expired.
|
||||
{'$match': {
|
||||
'user.badges.expires': {'$not': {'$gt': now}}
|
||||
}},
|
||||
|
||||
# Make sure that the badges that expire last are also refreshed last.
|
||||
{'$sort': {'user.badges.expires': 1}},
|
||||
|
||||
# Reduce the document to the info we're after.
|
||||
{'$project': {
|
||||
'token': True,
|
||||
'user._id': True,
|
||||
'user.auth.user_id': True,
|
||||
'user.badges.expires': True,
|
||||
}},
|
||||
])
|
||||
|
||||
log.debug('Aggregating tokens and users')
|
||||
for user_info in cursor:
|
||||
log.debug('User %s has badges %s',
|
||||
user_info['user']['_id'], user_info['user'].get('badges'))
|
||||
yield SyncUser(
|
||||
user_id=user_info['user']['_id'],
|
||||
token=user_info['token'],
|
||||
bid_user_id=user_info['user']['auth']['user_id'])
|
||||
|
||||
|
||||
def fetch_badge_html(session: requests.Session, user: SyncUser, size: str) \
|
||||
-> str:
|
||||
"""Fetch a Blender ID badge for this user.
|
||||
|
||||
:param session:
|
||||
:param user:
|
||||
:param size: Size indication for the badge images, see the Blender ID
|
||||
documentation/code. As of this writing valid sizes are {'s', 'm', 'l'}.
|
||||
"""
|
||||
my_log = log.getChild('fetch_badge_html')
|
||||
|
||||
blender_id_endpoint = current_app.config['BLENDER_ID_ENDPOINT']
|
||||
url = urljoin(blender_id_endpoint, f'api/badges/{user.bid_user_id}/html/{size}')
|
||||
|
||||
my_log.debug('Fetching badge HTML at %s for user %s', url, user.user_id)
|
||||
try:
|
||||
resp = session.get(url, headers={'Authorization': f'Bearer {user.token}'})
|
||||
except requests.ConnectionError as ex:
|
||||
my_log.warning('Unable to connect to Blender ID at %s: %s', url, ex)
|
||||
raise StopRefreshing()
|
||||
|
||||
if resp.status_code == 204:
|
||||
my_log.debug('No badges for user %s', user.user_id)
|
||||
return ''
|
||||
if resp.status_code == 403:
|
||||
my_log.warning('Tried fetching %s for user %s but received a 403: %s',
|
||||
url, user.user_id, resp.text)
|
||||
return ''
|
||||
if resp.status_code == 400:
|
||||
my_log.warning('Blender ID did not accept our GET request at %s for user %s: %s',
|
||||
url, user.user_id, resp.text)
|
||||
return ''
|
||||
if resp.status_code == 500:
|
||||
my_log.warning('Blender ID returned an internal server error on %s for user %s, '
|
||||
'aborting all badge refreshes: %s', url, user.user_id, resp.text)
|
||||
raise StopRefreshing()
|
||||
if resp.status_code == 404:
|
||||
my_log.warning('Blender ID has no user %s for our user %s', user.bid_user_id, user.user_id)
|
||||
return ''
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
|
||||
|
||||
def refresh_all_badges(only_user_id: typing.Optional[bson.ObjectId] = None, *,
|
||||
dry_run=False,
|
||||
timelimit: datetime.timedelta):
|
||||
"""Re-fetch all badges for all users, except when already refreshed recently.
|
||||
|
||||
:param only_user_id: Only refresh this user. This is expected to be used
|
||||
sparingly during manual maintenance / debugging sessions only. It does
|
||||
fetch all users to refresh, and in Python code skips all except the
|
||||
given one.
|
||||
:param dry_run: if True the changes are described in the log, but not performed.
|
||||
:param timelimit: Refreshing will stop after this time. This allows for cron(-like)
|
||||
jobs to run without overlapping, even when the number fo badges to refresh
|
||||
becomes larger than possible within the period of the cron job.
|
||||
"""
|
||||
from requests.adapters import HTTPAdapter
|
||||
my_log = log.getChild('fetch_badge_html')
|
||||
|
||||
# Test the config before we start looping over the world.
|
||||
badge_expiry = badge_expiry_config()
|
||||
if not badge_expiry or not isinstance(badge_expiry, datetime.timedelta):
|
||||
raise ValueError('BLENDER_ID_BADGE_EXPIRY not configured properly, should be a timedelta')
|
||||
|
||||
session = requests.Session()
|
||||
session.mount('https://', HTTPAdapter(max_retries=5))
|
||||
users_coll = current_app.db('users')
|
||||
|
||||
deadline = utcnow() + timelimit
|
||||
|
||||
num_updates = 0
|
||||
for user_info in find_users_to_sync():
|
||||
if utcnow() > deadline:
|
||||
my_log.info('Stopping badge refresh because the timelimit %s (H:MM:SS) was hit.',
|
||||
timelimit)
|
||||
break
|
||||
|
||||
if only_user_id and user_info.user_id != only_user_id:
|
||||
my_log.debug('Skipping user %s', user_info.user_id)
|
||||
continue
|
||||
try:
|
||||
badge_html = fetch_badge_html(session, user_info, 's')
|
||||
except StopRefreshing:
|
||||
my_log.error('Blender ID has internal problems, stopping badge refreshing at user %s',
|
||||
user_info)
|
||||
break
|
||||
|
||||
update = {'badges': {
|
||||
'html': badge_html,
|
||||
'expires': utcnow() + badge_expiry,
|
||||
}}
|
||||
num_updates += 1
|
||||
my_log.info('Updating badges HTML for Blender ID %s, user %s',
|
||||
user_info.bid_user_id, user_info.user_id)
|
||||
if not dry_run:
|
||||
result = users_coll.update_one({'_id': user_info.user_id},
|
||||
{'$set': update})
|
||||
if result.matched_count != 1:
|
||||
my_log.warning('Unable to update badges for user %s', user_info.user_id)
|
||||
my_log.info('Updated badges of %d users%s', num_updates, ' (dry-run)' if dry_run else '')
|
||||
|
||||
|
||||
def badge_expiry_config() -> datetime.timedelta:
|
||||
return current_app.config.get('BLENDER_ID_BADGE_EXPIRY')
|
20
pillar/celery/badges.py
Normal file
20
pillar/celery/badges.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Badge HTML synchronisation.
|
||||
|
||||
Note that this module can only be imported when an application context is
|
||||
active. Best to late-import this in the functions where it's needed.
|
||||
"""
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from pillar import current_app, badge_sync
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@current_app.celery.task(ignore_result=True)
|
||||
def sync_badges_for_users(timelimit_seconds: int):
|
||||
"""Synchronises Blender ID badges for the most-urgent users."""
|
||||
|
||||
timelimit = datetime.timedelta(seconds=timelimit_seconds)
|
||||
log.info('Refreshing badges, timelimit is %s (H:MM:SS)', timelimit)
|
||||
badge_sync.refresh_all_badges(timelimit=timelimit)
|
@@ -13,6 +13,7 @@ from pillar.cli.maintenance import manager_maintenance
|
||||
from pillar.cli.operations import manager_operations
|
||||
from pillar.cli.setup import manager_setup
|
||||
from pillar.cli.elastic import manager_elastic
|
||||
from . import badges
|
||||
|
||||
from pillar.cli import translations
|
||||
|
||||
@@ -24,3 +25,4 @@ manager.add_command("maintenance", manager_maintenance)
|
||||
manager.add_command("setup", manager_setup)
|
||||
manager.add_command("operations", manager_operations)
|
||||
manager.add_command("elastic", manager_elastic)
|
||||
manager.add_command("badges", badges.manager)
|
||||
|
39
pillar/cli/badges.py
Normal file
39
pillar/cli/badges.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from flask_script import Manager
|
||||
from pillar import current_app, badge_sync
|
||||
from pillar.api.utils import utcnow
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
manager = Manager(current_app, usage="Badge operations")
|
||||
|
||||
|
||||
@manager.option('-u', '--user', dest='email', default='', help='Email address of the user to sync')
|
||||
@manager.option('-a', '--all', dest='sync_all', action='store_true', default=False,
|
||||
help='Sync all users')
|
||||
@manager.option('--go', action='store_true', default=False,
|
||||
help='Actually perform the sync; otherwise it is a dry-run.')
|
||||
def sync(email: str = '', sync_all: bool=False, go: bool=False):
|
||||
if bool(email) == bool(sync_all):
|
||||
raise ValueError('Use either --user or --all.')
|
||||
|
||||
if email:
|
||||
users_coll = current_app.db('users')
|
||||
db_user = users_coll.find_one({'email': email}, projection={'_id': True})
|
||||
if not db_user:
|
||||
raise ValueError(f'No user with email {email!r} found')
|
||||
specific_user = db_user['_id']
|
||||
else:
|
||||
specific_user = None
|
||||
|
||||
if not go:
|
||||
log.info('Performing dry-run, not going to change the user database.')
|
||||
start_time = utcnow()
|
||||
badge_sync.refresh_all_badges(specific_user, dry_run=not go,
|
||||
timelimit=datetime.timedelta(hours=1))
|
||||
end_time = utcnow()
|
||||
log.info('%s took %s (H:MM:SS)',
|
||||
'Updating user badges' if go else 'Dry-run',
|
||||
end_time - start_time)
|
@@ -684,8 +684,8 @@ def upgrade_attachment_schema(proj_url=None, all_projects=False, go=False):
|
||||
log_proj()
|
||||
log.info('Removed %d empty attachment dicts', res.modified_count)
|
||||
else:
|
||||
to_remove = nodes_coll.count({'properties.attachments': {},
|
||||
'project': project['_id']})
|
||||
to_remove = nodes_coll.count_documents({'properties.attachments': {},
|
||||
'project': project['_id']})
|
||||
if to_remove:
|
||||
log_proj()
|
||||
log.info('Would remove %d empty attachment dicts', to_remove)
|
||||
@@ -767,7 +767,9 @@ def iter_markdown(proj_node_types: dict, some_node: dict, callback: typing.Calla
|
||||
continue
|
||||
to_visit.append((subdoc, definition['schema']))
|
||||
continue
|
||||
if definition.get('coerce') != 'markdown':
|
||||
coerce = definition.get('coerce') # Eve < 0.8
|
||||
validator = definition.get('validator') # Eve >= 0.8
|
||||
if coerce != 'markdown' and validator != 'markdown':
|
||||
continue
|
||||
|
||||
my_log.debug('I have to change %r of %s', key, doc)
|
||||
|
@@ -1,6 +1,8 @@
|
||||
from collections import defaultdict
|
||||
import datetime
|
||||
import os.path
|
||||
from os import getenv
|
||||
from collections import defaultdict
|
||||
|
||||
import requests.certs
|
||||
|
||||
# Certificate file for communication with other systems.
|
||||
@@ -29,10 +31,11 @@ DEBUG = False
|
||||
SECRET_KEY = ''
|
||||
|
||||
# Authentication token hashing key. If empty falls back to UTF8-encoded SECRET_KEY with a warning.
|
||||
# Not used to hash new tokens, but it is used to check pre-existing hashed tokens.
|
||||
AUTH_TOKEN_HMAC_KEY = b''
|
||||
|
||||
# Authentication settings
|
||||
BLENDER_ID_ENDPOINT = 'http://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'},
|
||||
|
@@ -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)
|
||||
|
@@ -162,9 +162,12 @@ class YouTube:
|
||||
if not youtube_id:
|
||||
return html_module.escape('{youtube invalid YouTube ID/URL}')
|
||||
|
||||
src = f'https://www.youtube.com/embed/{youtube_id}?rel=0'
|
||||
html = f'<iframe class="shortcode youtube" width="{width}" height="{height}" src="{src}"' \
|
||||
f' frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>'
|
||||
src = f'https://www.youtube.com/embed/{youtube_id}?rel=0'
|
||||
html = f'<div class="embed-responsive embed-responsive-16by9">' \
|
||||
f'<iframe class="shortcode youtube embed-responsive-item"' \
|
||||
f' width="{width}" height="{height}" src="{src}"' \
|
||||
f' frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>' \
|
||||
f'</div>'
|
||||
return html
|
||||
|
||||
|
||||
@@ -225,12 +228,25 @@ class Attachment:
|
||||
|
||||
return self.render(file_doc, pargs, kwargs)
|
||||
|
||||
def sdk_file(self, slug: str, node_properties: dict) -> pillarsdk.File:
|
||||
def sdk_file(self, slug: str, document: dict) -> pillarsdk.File:
|
||||
"""Return the file document for the attachment with this slug."""
|
||||
|
||||
from pillar.web import system_util
|
||||
|
||||
attachments = node_properties.get('attachments', {})
|
||||
# TODO (fsiddi) Make explicit what 'document' is.
|
||||
# In some cases we pass the entire node or project documents, in other cases
|
||||
# we pass node.properties. This should be unified at the level of do_markdown.
|
||||
# For now we do a quick hack and first look for 'properties' in the doc,
|
||||
# then we look for 'attachments'.
|
||||
|
||||
doc_properties = document.get('properties')
|
||||
if doc_properties:
|
||||
# We passed an entire document (all nodes must have 'properties')
|
||||
attachments = doc_properties.get('attachments', {})
|
||||
else:
|
||||
# The value of document could have been defined as 'node.properties'
|
||||
attachments = document.get('attachments', {})
|
||||
|
||||
attachment = attachments.get(slug)
|
||||
if not attachment:
|
||||
raise self.NoSuchSlug(slug)
|
||||
|
@@ -1,6 +1,7 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import copy
|
||||
import datetime
|
||||
import json
|
||||
@@ -10,11 +11,7 @@ import pathlib
|
||||
import sys
|
||||
import typing
|
||||
import unittest.mock
|
||||
|
||||
try:
|
||||
from urllib.parse import urlencode
|
||||
except ImportError:
|
||||
from urllib.parse import urlencode
|
||||
from urllib.parse import urlencode, urljoin
|
||||
|
||||
from bson import ObjectId, tz_util
|
||||
|
||||
@@ -27,6 +24,7 @@ from eve.tests import TestMinimal
|
||||
import pymongo.collection
|
||||
from flask.testing import FlaskClient
|
||||
import flask.ctx
|
||||
import flask.wrappers
|
||||
import responses
|
||||
|
||||
import pillar
|
||||
@@ -185,7 +183,7 @@ class AbstractPillarTest(TestMinimal):
|
||||
else:
|
||||
self.ensure_project_exists()
|
||||
|
||||
with self.app.test_request_context():
|
||||
with self.app.app_context():
|
||||
files_collection = self.app.data.driver.db['files']
|
||||
assert isinstance(files_collection, pymongo.collection.Collection)
|
||||
|
||||
@@ -326,15 +324,46 @@ class AbstractPillarTest(TestMinimal):
|
||||
|
||||
return user
|
||||
|
||||
def create_valid_auth_token(self, user_id, token='token'):
|
||||
@contextlib.contextmanager
|
||||
def login_as(self, user_id: typing.Union[str, ObjectId]):
|
||||
"""Context manager, within the context the app context is active and the user logged in.
|
||||
|
||||
The logging-in happens when a request starts, so it's only active when
|
||||
e.g. self.get() or self.post() or somesuch request is used.
|
||||
"""
|
||||
from pillar.auth import UserClass, login_user_object
|
||||
|
||||
if isinstance(user_id, str):
|
||||
user_oid = ObjectId(user_id)
|
||||
elif isinstance(user_id, ObjectId):
|
||||
user_oid = user_id
|
||||
else:
|
||||
raise TypeError(f'invalid type {type(user_id)} for parameter user_id')
|
||||
user_doc = self.fetch_user_from_db(user_oid)
|
||||
|
||||
def signal_handler(sender, **kwargs):
|
||||
login_user_object(user)
|
||||
|
||||
with self.app.app_context():
|
||||
user = UserClass.construct('', user_doc)
|
||||
with flask.request_started.connected_to(signal_handler, self.app):
|
||||
yield
|
||||
|
||||
# TODO: rename to 'create_auth_token' now that 'expire_in_days' can be negative.
|
||||
def create_valid_auth_token(self,
|
||||
user_id: ObjectId,
|
||||
token='token',
|
||||
*,
|
||||
oauth_scopes: typing.Optional[typing.List[str]]=None,
|
||||
expire_in_days=1) -> dict:
|
||||
from pillar.api.utils import utcnow
|
||||
|
||||
future = utcnow() + datetime.timedelta(days=1)
|
||||
future = utcnow() + datetime.timedelta(days=expire_in_days)
|
||||
|
||||
with self.app.test_request_context():
|
||||
from pillar.api.utils import authentication as auth
|
||||
|
||||
token_data = auth.store_token(user_id, token, future, None)
|
||||
token_data = auth.store_token(user_id, token, future, oauth_scopes=oauth_scopes)
|
||||
|
||||
return token_data
|
||||
|
||||
@@ -364,7 +393,7 @@ class AbstractPillarTest(TestMinimal):
|
||||
|
||||
return user_id
|
||||
|
||||
def create_node(self, node_doc):
|
||||
def create_node(self, node_doc) -> ObjectId:
|
||||
"""Creates a node, returning its ObjectId. """
|
||||
|
||||
with self.app.test_request_context():
|
||||
@@ -406,7 +435,7 @@ class AbstractPillarTest(TestMinimal):
|
||||
"""Sets up Responses to mock unhappy validation flow."""
|
||||
|
||||
responses.add(responses.POST,
|
||||
'%s/u/validate_token' % self.app.config['BLENDER_ID_ENDPOINT'],
|
||||
urljoin(self.app.config['BLENDER_ID_ENDPOINT'], 'u/validate_token'),
|
||||
json={'status': 'fail'},
|
||||
status=403)
|
||||
|
||||
@@ -414,7 +443,7 @@ class AbstractPillarTest(TestMinimal):
|
||||
"""Sets up Responses to mock happy validation flow."""
|
||||
|
||||
responses.add(responses.POST,
|
||||
'%s/u/validate_token' % self.app.config['BLENDER_ID_ENDPOINT'],
|
||||
urljoin(self.app.config['BLENDER_ID_ENDPOINT'], 'u/validate_token'),
|
||||
json=BLENDER_ID_USER_RESPONSE,
|
||||
status=200)
|
||||
|
||||
@@ -485,11 +514,10 @@ class AbstractPillarTest(TestMinimal):
|
||||
|
||||
def client_request(self, method, path, qs=None, expected_status=200, auth_token=None, json=None,
|
||||
data=None, headers=None, files=None, content_type=None, etag=None,
|
||||
environ_overrides=None):
|
||||
environ_overrides=None) -> flask.wrappers.Response:
|
||||
"""Performs a HTTP request to the server."""
|
||||
|
||||
from pillar.api.utils import dumps
|
||||
import json as mod_json
|
||||
|
||||
headers = headers or {}
|
||||
environ_overrides = environ_overrides or {}
|
||||
@@ -522,29 +550,21 @@ class AbstractPillarTest(TestMinimal):
|
||||
expected_status, resp.status_code, resp.data
|
||||
))
|
||||
|
||||
def get_json():
|
||||
if resp.mimetype != 'application/json':
|
||||
raise TypeError('Unable to load JSON from mimetype %r' % resp.mimetype)
|
||||
return mod_json.loads(resp.data)
|
||||
|
||||
resp.json = get_json
|
||||
resp.get_json = get_json
|
||||
|
||||
return resp
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
def get(self, *args, **kwargs) -> flask.wrappers.Response:
|
||||
return self.client_request('GET', *args, **kwargs)
|
||||
|
||||
def post(self, *args, **kwargs):
|
||||
def post(self, *args, **kwargs) -> flask.wrappers.Response:
|
||||
return self.client_request('POST', *args, **kwargs)
|
||||
|
||||
def put(self, *args, **kwargs):
|
||||
def put(self, *args, **kwargs) -> flask.wrappers.Response:
|
||||
return self.client_request('PUT', *args, **kwargs)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
def delete(self, *args, **kwargs) -> flask.wrappers.Response:
|
||||
return self.client_request('DELETE', *args, **kwargs)
|
||||
|
||||
def patch(self, *args, **kwargs):
|
||||
def patch(self, *args, **kwargs) -> flask.wrappers.Response:
|
||||
return self.client_request('PATCH', *args, **kwargs)
|
||||
|
||||
def assertAllowsAccess(self,
|
||||
@@ -561,7 +581,7 @@ class AbstractPillarTest(TestMinimal):
|
||||
raise TypeError('expected_user_id should be a string or ObjectId, '
|
||||
f'but is {expected_user_id!r}')
|
||||
|
||||
resp = self.get('/api/users/me', expected_status=200, auth_token=token).json()
|
||||
resp = self.get('/api/users/me', expected_status=200, auth_token=token).get_json()
|
||||
|
||||
if expected_user_id:
|
||||
self.assertEqual(resp['_id'], str(expected_user_id))
|
||||
|
@@ -1,9 +1,9 @@
|
||||
"""Flask configuration file for unit testing."""
|
||||
|
||||
BLENDER_ID_ENDPOINT = 'http://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
|
||||
|
@@ -1,6 +1,7 @@
|
||||
from pillar.api.eve_settings import *
|
||||
|
||||
MONGO_DBNAME = 'pillar_test'
|
||||
MONGO_USERNAME = None
|
||||
|
||||
|
||||
def override_eve():
|
||||
@@ -10,5 +11,7 @@ def override_eve():
|
||||
test_settings.MONGO_HOST = MONGO_HOST
|
||||
test_settings.MONGO_PORT = MONGO_PORT
|
||||
test_settings.MONGO_DBNAME = MONGO_DBNAME
|
||||
test_settings.MONGO1_USERNAME = MONGO_USERNAME
|
||||
tests.MONGO_HOST = MONGO_HOST
|
||||
tests.MONGO_DBNAME = MONGO_DBNAME
|
||||
tests.MONGO_USERNAME = MONGO_USERNAME
|
||||
|
@@ -21,7 +21,7 @@ def attachment_form_group_create(schema_prop):
|
||||
def _attachment_build_single_field(schema_prop):
|
||||
# Ugly hard-coded schema.
|
||||
fake_schema = {
|
||||
'slug': schema_prop['propertyschema'],
|
||||
'slug': schema_prop['keyschema'],
|
||||
'oid': schema_prop['valueschema']['schema']['oid'],
|
||||
}
|
||||
file_select_form_group = build_file_select_form(fake_schema)
|
||||
|
@@ -61,16 +61,10 @@ def posts_view(project_id=None, project_url=None, url=None, *, archive=False, pa
|
||||
post.picture = get_file(post.picture, api=api)
|
||||
post.url = url_for_node(node=post)
|
||||
|
||||
# Use the *_main_project.html template for the main blog
|
||||
is_main_project = project_id == current_app.config['MAIN_PROJECT_ID']
|
||||
main_project_template = '_main_project' if is_main_project else ''
|
||||
main_project_template = '_main_project'
|
||||
index_arch = 'archive' if archive else 'index'
|
||||
template_path = f'nodes/custom/blog/{index_arch}{main_project_template}.html',
|
||||
template_path = f'nodes/custom/blog/{index_arch}.html',
|
||||
|
||||
if url:
|
||||
template_path = f'nodes/custom/post/view{main_project_template}.html',
|
||||
|
||||
post = Node.find_one({
|
||||
'where': {'parent': blog._id, 'properties.url': url},
|
||||
'embedded': {'node_type': 1, 'user': 1},
|
||||
@@ -95,6 +89,7 @@ def posts_view(project_id=None, project_url=None, url=None, *, archive=False, pa
|
||||
can_create_blog_posts = project.node_type_has_method('post', 'POST', api=api)
|
||||
|
||||
# Use functools.partial so we can later pass page=X.
|
||||
is_main_project = project_id == current_app.config['MAIN_PROJECT_ID']
|
||||
if is_main_project:
|
||||
url_func = functools.partial(url_for, 'main.main_blog_archive')
|
||||
else:
|
||||
@@ -121,7 +116,7 @@ def posts_view(project_id=None, project_url=None, url=None, *, archive=False, pa
|
||||
return render_template(
|
||||
template_path,
|
||||
blog=blog,
|
||||
node=post,
|
||||
node=post, # node is used by the generic comments rendering (see custom/_scripts.pug)
|
||||
posts=posts._items,
|
||||
posts_meta=pmeta,
|
||||
more_posts_available=pmeta['total'] > pmeta['max_results'],
|
||||
|
@@ -94,6 +94,16 @@ def find_for_post(project, node):
|
||||
url=node.properties.url)
|
||||
|
||||
|
||||
@register_node_finder('page')
|
||||
def find_for_page(project, node):
|
||||
"""Returns the URL for a page."""
|
||||
|
||||
project_id = project['_id']
|
||||
|
||||
the_project = project_url(project_id, project=project)
|
||||
return url_for('projects.view_node', project_url=the_project.url, node_id=node.properties.url)
|
||||
|
||||
|
||||
def find_for_other(project, node):
|
||||
"""Fallback: Assets, textures, and other node types.
|
||||
|
||||
|
@@ -1,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':
|
||||
|
@@ -24,6 +24,7 @@ from pillar import current_app
|
||||
from pillar.api.utils import utcnow
|
||||
from pillar.web import system_util
|
||||
from pillar.web import utils
|
||||
from pillar.web.nodes import finders
|
||||
from pillar.web.utils.jstree import jstree_get_children
|
||||
import pillar.extension
|
||||
|
||||
@@ -302,6 +303,52 @@ def view(project_url):
|
||||
'header_video_node': header_video_node})
|
||||
|
||||
|
||||
def project_navigation_links(project, api) -> list:
|
||||
"""Returns a list of nodes for the project, for top navigation display.
|
||||
|
||||
Args:
|
||||
project: A Project object.
|
||||
api: the api client credential.
|
||||
|
||||
Returns:
|
||||
A list of links for the Project.
|
||||
For example we display a link to the project blog if present, as well
|
||||
as pages. The list is structured as follows:
|
||||
|
||||
[{'url': '/p/spring/about', 'label': 'About'},
|
||||
{'url': '/p/spring/blog', 'label': 'Blog'}]
|
||||
"""
|
||||
|
||||
links = []
|
||||
|
||||
# Fetch the blog
|
||||
blog = Node.find_first({
|
||||
'where': {'project': project._id, 'node_type': 'blog', '_deleted': {'$ne': True}},
|
||||
'projection': {
|
||||
'name': 1,
|
||||
}
|
||||
}, api=api)
|
||||
|
||||
if blog:
|
||||
links.append({'url': finders.find_url_for_node(blog), 'label': blog.name})
|
||||
|
||||
# Fetch pages
|
||||
pages = Node.all({
|
||||
'where': {'project': project._id, 'node_type': 'page', '_deleted': {'$ne': True}},
|
||||
'projection': {
|
||||
'name': 1,
|
||||
'properties.url': 1
|
||||
}
|
||||
}, api=api)
|
||||
|
||||
# Process the results and append the links to the list
|
||||
for p in pages._items:
|
||||
|
||||
links.append({'url': finders.find_url_for_node(p), 'label': p.name})
|
||||
|
||||
return links
|
||||
|
||||
|
||||
def render_project(project, api, extra_context=None, template_name=None):
|
||||
project.picture_square = utils.get_file(project.picture_square, api=api)
|
||||
project.picture_header = utils.get_file(project.picture_header, api=api)
|
||||
@@ -370,6 +417,8 @@ def render_project(project, api, extra_context=None, template_name=None):
|
||||
|
||||
extension_sidebar_links = current_app.extension_sidebar_links(project)
|
||||
|
||||
navigation_links = project_navigation_links(project, api)
|
||||
|
||||
return render_template(template_name,
|
||||
api=api,
|
||||
project=project,
|
||||
@@ -378,6 +427,7 @@ def render_project(project, api, extra_context=None, template_name=None):
|
||||
show_project=True,
|
||||
og_picture=project.picture_header,
|
||||
activity_stream=activity_stream,
|
||||
navigation_links=navigation_links,
|
||||
extension_sidebar_links=extension_sidebar_links,
|
||||
**extra_context)
|
||||
|
||||
@@ -447,16 +497,14 @@ def view_node(project_url, node_id):
|
||||
|
||||
# Append _theatre to load the proper template
|
||||
theatre = '_theatre' if theatre_mode else ''
|
||||
navigation_links = project_navigation_links(project, api)
|
||||
|
||||
if node.node_type == 'page':
|
||||
pages = Node.all({
|
||||
'where': {'project': project._id, 'node_type': 'page'},
|
||||
'projection': {'name': 1}}, api=api)
|
||||
return render_template('nodes/custom/page/view_embed.html',
|
||||
api=api,
|
||||
node=node,
|
||||
project=project,
|
||||
pages=pages._items,
|
||||
navigation_links=navigation_links,
|
||||
og_picture=og_picture,)
|
||||
|
||||
extension_sidebar_links = current_app.extension_sidebar_links(project)
|
||||
@@ -468,6 +516,7 @@ def view_node(project_url, node_id):
|
||||
show_node=True,
|
||||
show_project=False,
|
||||
og_picture=og_picture,
|
||||
navigation_links=navigation_links,
|
||||
extension_sidebar_links=extension_sidebar_links)
|
||||
|
||||
|
||||
|
@@ -872,12 +872,6 @@
|
||||
"code": 61930,
|
||||
"src": "fontawesome"
|
||||
},
|
||||
{
|
||||
"uid": "31972e4e9d080eaa796290349ae6c1fd",
|
||||
"css": "users",
|
||||
"code": 59502,
|
||||
"src": "fontawesome"
|
||||
},
|
||||
{
|
||||
"uid": "c8585e1e5b0467f28b70bce765d5840c",
|
||||
"css": "clipboard-copy",
|
||||
@@ -990,6 +984,30 @@
|
||||
"code": 59394,
|
||||
"src": "entypo"
|
||||
},
|
||||
{
|
||||
"uid": "347c38a8b96a509270fdcabc951e7571",
|
||||
"css": "database",
|
||||
"code": 61888,
|
||||
"src": "fontawesome"
|
||||
},
|
||||
{
|
||||
"uid": "3a6f0140c3a390bdb203f56d1bfdefcb",
|
||||
"css": "speed",
|
||||
"code": 59471,
|
||||
"src": "entypo"
|
||||
},
|
||||
{
|
||||
"uid": "4c1ef492f1d2c39a2250ae457cee2a6e",
|
||||
"css": "social-instagram",
|
||||
"code": 61805,
|
||||
"src": "fontawesome"
|
||||
},
|
||||
{
|
||||
"uid": "e36d581e4f2844db345bddc205d15dda",
|
||||
"css": "users",
|
||||
"code": 59507,
|
||||
"src": "elusive"
|
||||
},
|
||||
{
|
||||
"uid": "053a214a098a9453877363eeb45f004e",
|
||||
"css": "log-in",
|
||||
|
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -33,7 +33,8 @@ def get_user_info(user_id):
|
||||
# TODO: put those fields into a config var or module-level global.
|
||||
return {'email': user.email,
|
||||
'full_name': user.full_name,
|
||||
'username': user.username}
|
||||
'username': user.username,
|
||||
'badges_html': (user.badges and user.badges.html) or ''}
|
||||
|
||||
|
||||
def setup_app(app):
|
||||
|
@@ -48,6 +48,10 @@ def oauth_authorize(provider):
|
||||
|
||||
@blueprint.route('/oauth/<provider>/authorized')
|
||||
def oauth_callback(provider):
|
||||
import datetime
|
||||
from pillar.api.utils.authentication import store_token
|
||||
from pillar.api.utils import utcnow
|
||||
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.homepage'))
|
||||
|
||||
@@ -65,7 +69,17 @@ def oauth_callback(provider):
|
||||
user_info = {'id': oauth_user.id, 'email': oauth_user.email, 'full_name': ''}
|
||||
db_user = find_user_in_db(user_info, provider=provider)
|
||||
db_id, status = upsert_user(db_user)
|
||||
token = generate_and_store_token(db_id)
|
||||
|
||||
# TODO(Sybren): If the user doesn't have any badges, but the access token
|
||||
# does have 'badge' scope, we should fetch the badges in the background.
|
||||
|
||||
if oauth_user.access_token:
|
||||
# TODO(Sybren): make nr of days configurable, or get from OAuthSignIn subclass.
|
||||
token_expiry = utcnow() + datetime.timedelta(days=15)
|
||||
token = store_token(db_id, oauth_user.access_token, token_expiry,
|
||||
oauth_scopes=oauth_user.scopes)
|
||||
else:
|
||||
token = generate_and_store_token(db_id)
|
||||
|
||||
# Login user
|
||||
pillar.auth.login_user(token['token'], load_from_db=True)
|
||||
|
@@ -62,7 +62,7 @@ def jstree_get_children(node_id, project_id=None):
|
||||
'where': {
|
||||
'$and': [
|
||||
{'node_type': {'$regex': '^(?!attract_)'}},
|
||||
{'node_type': {'$not': {'$in': ['comment', 'post']}}},
|
||||
{'node_type': {'$not': {'$in': ['comment', 'post', 'blog', 'page']}}},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
|
2
setup.py
2
setup.py
@@ -35,7 +35,7 @@ setuptools.setup(
|
||||
install_requires=[
|
||||
'Flask>=0.12',
|
||||
'Eve>=0.7.3',
|
||||
'Flask-Cache>=0.13.1',
|
||||
'Flask-Caching>=1.4.0',
|
||||
'Flask-Script>=2.0.5',
|
||||
'Flask-Login>=0.3.2',
|
||||
'Flask-OAuthlib>=0.9.3',
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -1,116 +0,0 @@
|
||||
(function () {
|
||||
var output, Converter;
|
||||
if (typeof exports === "object" && typeof require === "function") { // we're in a CommonJS (e.g. Node.js) module
|
||||
output = exports;
|
||||
Converter = require("./Markdown.Converter").Converter;
|
||||
} else {
|
||||
output = window.Markdown;
|
||||
Converter = output.Converter;
|
||||
}
|
||||
|
||||
output.getSanitizingConverter = function () {
|
||||
var converter = new Converter();
|
||||
converter.hooks.chain("postConversion", sanitizeHtml);
|
||||
converter.hooks.chain("postConversion", balanceTags);
|
||||
return converter;
|
||||
}
|
||||
|
||||
function sanitizeHtml(html) {
|
||||
return html.replace(/<[^>]*>?/gi, sanitizeTag);
|
||||
}
|
||||
|
||||
// (tags that can be opened/closed) | (tags that stand alone)
|
||||
var basic_tag_whitelist = /^(<\/?(b|blockquote|code|del|dd|dl|dt|em|h1|h2|h3|i|iframe|kbd|li|ol(?: start="\d+")?|p|pre|s|sup|sub|strong|strike|ul|video)>|<(br|hr)\s?\/?>)$/i;
|
||||
// <a href="url..." optional title>|</a>
|
||||
var a_white = /^(<a\shref="((https?|ftp):\/\/|\/)[-A-Za-z0-9+&@#\/%?=~_|!:,.;\(\)*[\]$]+"(\stitle="[^"<>]+")?(\sclass="[^"<>]+")?\s?>|<\/a>)$/i;
|
||||
|
||||
// Cloud custom: Allow iframe embed from YouTube, Vimeo and SoundCloud
|
||||
var iframe_youtube = /^(<iframe(\swidth="\d{1,3}")?(\sheight="\d{1,3}")\ssrc="((https?):\/\/|\/)[-A-Za-z0-9+&@#\/%?=~_|!:,.;\(\)*[\]$]+"(\sframeborder="\d{1,3}")?(\sallowfullscreen)\s?>|<\/iframe>)$/i;
|
||||
var iframe_vimeo = /^(<iframe(\ssrc="((https?):\/\/|\/)[-A-Za-z0-9+&@#\/%?=~_|!:,.;\(\)*[\]$]+"?\swidth="\d{1,3}")?(\sheight="\d{1,3}")?(\sframeborder="\d{1,3}")?(\swebkitallowfullscreen)\s?(\smozallowfullscreen)\s?(\sallowfullscreen)\s?>|<\/iframe>)$/i;
|
||||
var iframe_soundcloud = /^(<iframe(\swidth="\d{1,3}\%")?(\sheight="\d{1,3}")?(\sscrolling="(?:yes|no)")?(\sframeborder="(?:yes|no)")\ssrc="((https?):\/\/|\/)[-A-Za-z0-9+&@#\/%?=~_|!:,.;\(\)*[\]$]+"\s?>|<\/iframe>)$/i;
|
||||
var iframe_googlestorage = /^(<iframe\ssrc="https:\/\/storage.googleapis.com\/institute-storage\/.+"\sstyle=".*"\s?>|<\/iframe>)$/i;
|
||||
|
||||
// <img src="url..." optional width optional height optional alt optional title
|
||||
var img_white = /^(<img\ssrc="(https?:\/\/|\/)[-A-Za-z0-9+&@#\/%?=~_|!:,.;\(\)*[\]$]+"(\swidth="\d{1,3}")?(\sheight="\d{1,3}")?(\salt="[^"<>]*")?(\stitle="[^"<>]*")?\s?\/?>)$/i;
|
||||
var video_white = /<video(.*?)>/;
|
||||
|
||||
function sanitizeTag(tag) {
|
||||
if (tag.match(basic_tag_whitelist) || tag.match(a_white) || tag.match(img_white) || tag.match(iframe_youtube) || tag.match(iframe_vimeo) || tag.match(iframe_soundcloud) || tag.match(iframe_googlestorage) || tag.match(video_white)) {
|
||||
return tag;
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// attempt to balance HTML tags in the html string
|
||||
/// by removing any unmatched opening or closing tags
|
||||
/// IMPORTANT: we *assume* HTML has *already* been
|
||||
/// sanitized and is safe/sane before balancing!
|
||||
///
|
||||
/// adapted from CODESNIPPET: A8591DBA-D1D3-11DE-947C-BA5556D89593
|
||||
/// </summary>
|
||||
function balanceTags(html) {
|
||||
|
||||
if (html == "")
|
||||
return "";
|
||||
|
||||
var re = /<\/?\w+[^>]*(\s|$|>)/g;
|
||||
// convert everything to lower case; this makes
|
||||
// our case insensitive comparisons easier
|
||||
var tags = html.toLowerCase().match(re);
|
||||
|
||||
// no HTML tags present? nothing to do; exit now
|
||||
var tagcount = (tags || []).length;
|
||||
if (tagcount == 0)
|
||||
return html;
|
||||
|
||||
var tagname, tag;
|
||||
var ignoredtags = "<p><img><br><li><hr>";
|
||||
var match;
|
||||
var tagpaired = [];
|
||||
var tagremove = [];
|
||||
var needsRemoval = false;
|
||||
|
||||
// loop through matched tags in forward order
|
||||
for (var ctag = 0; ctag < tagcount; ctag++) {
|
||||
tagname = tags[ctag].replace(/<\/?(\w+).*/, "$1");
|
||||
// skip any already paired tags
|
||||
// and skip tags in our ignore list; assume they're self-closed
|
||||
if (tagpaired[ctag] || ignoredtags.search("<" + tagname + ">") > -1)
|
||||
continue;
|
||||
|
||||
tag = tags[ctag];
|
||||
match = -1;
|
||||
|
||||
if (!/^<\//.test(tag)) {
|
||||
// this is an opening tag
|
||||
// search forwards (next tags), look for closing tags
|
||||
for (var ntag = ctag + 1; ntag < tagcount; ntag++) {
|
||||
if (!tagpaired[ntag] && tags[ntag] == "</" + tagname + ">") {
|
||||
match = ntag;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (match == -1)
|
||||
needsRemoval = tagremove[ctag] = true; // mark for removal
|
||||
else
|
||||
tagpaired[match] = true; // mark paired
|
||||
}
|
||||
|
||||
if (!needsRemoval)
|
||||
return html;
|
||||
|
||||
// delete all orphaned tags from the string
|
||||
|
||||
var ctag = 0;
|
||||
html = html.replace(re, function (match) {
|
||||
var res = tagremove[ctag] ? "" : match;
|
||||
ctag++;
|
||||
return res;
|
||||
});
|
||||
return html;
|
||||
}
|
||||
})();
|
File diff suppressed because it is too large
Load Diff
@@ -1,874 +0,0 @@
|
||||
(function () {
|
||||
// A quick way to make sure we're only keeping span-level tags when we need to.
|
||||
// This isn't supposed to be foolproof. It's just a quick way to make sure we
|
||||
// keep all span-level tags returned by a pagedown converter. It should allow
|
||||
// all span-level tags through, with or without attributes.
|
||||
var inlineTags = new RegExp(['^(<\\/?(a|abbr|acronym|applet|area|b|basefont|',
|
||||
'bdo|big|button|cite|code|del|dfn|em|figcaption|',
|
||||
'font|i|iframe|img|input|ins|kbd|label|map|',
|
||||
'mark|meter|object|param|progress|q|ruby|rp|rt|s|',
|
||||
'samp|script|select|small|span|strike|strong|',
|
||||
'sub|sup|textarea|time|tt|u|var|wbr)[^>]*>|',
|
||||
'<(br)\\s?\\/?>)$'].join(''), 'i');
|
||||
|
||||
/******************************************************************
|
||||
* Utility Functions *
|
||||
*****************************************************************/
|
||||
|
||||
// patch for ie7
|
||||
if (!Array.indexOf) {
|
||||
Array.prototype.indexOf = function(obj) {
|
||||
for (var i = 0; i < this.length; i++) {
|
||||
if (this[i] == obj) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
}
|
||||
|
||||
function trim(str) {
|
||||
return str.replace(/^\s+|\s+$/g, '');
|
||||
}
|
||||
|
||||
function rtrim(str) {
|
||||
return str.replace(/\s+$/g, '');
|
||||
}
|
||||
|
||||
// Remove one level of indentation from text. Indent is 4 spaces.
|
||||
function outdent(text) {
|
||||
return text.replace(new RegExp('^(\\t|[ ]{1,4})', 'gm'), '');
|
||||
}
|
||||
|
||||
function contains(str, substr) {
|
||||
return str.indexOf(substr) != -1;
|
||||
}
|
||||
|
||||
// Sanitize html, removing tags that aren't in the whitelist
|
||||
function sanitizeHtml(html, whitelist) {
|
||||
return html.replace(/<[^>]*>?/gi, function(tag) {
|
||||
return tag.match(whitelist) ? tag : '';
|
||||
});
|
||||
}
|
||||
|
||||
// Merge two arrays, keeping only unique elements.
|
||||
function union(x, y) {
|
||||
var obj = {};
|
||||
for (var i = 0; i < x.length; i++)
|
||||
obj[x[i]] = x[i];
|
||||
for (i = 0; i < y.length; i++)
|
||||
obj[y[i]] = y[i];
|
||||
var res = [];
|
||||
for (var k in obj) {
|
||||
if (obj.hasOwnProperty(k))
|
||||
res.push(obj[k]);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// JS regexes don't support \A or \Z, so we add sentinels, as Pagedown
|
||||
// does. In this case, we add the ascii codes for start of text (STX) and
|
||||
// end of text (ETX), an idea borrowed from:
|
||||
// https://github.com/tanakahisateru/js-markdown-extra
|
||||
function addAnchors(text) {
|
||||
if(text.charAt(0) != '\x02')
|
||||
text = '\x02' + text;
|
||||
if(text.charAt(text.length - 1) != '\x03')
|
||||
text = text + '\x03';
|
||||
return text;
|
||||
}
|
||||
|
||||
// Remove STX and ETX sentinels.
|
||||
function removeAnchors(text) {
|
||||
if(text.charAt(0) == '\x02')
|
||||
text = text.substr(1);
|
||||
if(text.charAt(text.length - 1) == '\x03')
|
||||
text = text.substr(0, text.length - 1);
|
||||
return text;
|
||||
}
|
||||
|
||||
// Convert markdown within an element, retaining only span-level tags
|
||||
function convertSpans(text, extra) {
|
||||
return sanitizeHtml(convertAll(text, extra), inlineTags);
|
||||
}
|
||||
|
||||
// Convert internal markdown using the stock pagedown converter
|
||||
function convertAll(text, extra) {
|
||||
var result = extra.blockGamutHookCallback(text);
|
||||
// We need to perform these operations since we skip the steps in the converter
|
||||
result = unescapeSpecialChars(result);
|
||||
result = result.replace(/~D/g, "$$").replace(/~T/g, "~");
|
||||
result = extra.previousPostConversion(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Convert escaped special characters
|
||||
function processEscapesStep1(text) {
|
||||
// Markdown extra adds two escapable characters, `:` and `|`
|
||||
return text.replace(/\\\|/g, '~I').replace(/\\:/g, '~i');
|
||||
}
|
||||
function processEscapesStep2(text) {
|
||||
return text.replace(/~I/g, '|').replace(/~i/g, ':');
|
||||
}
|
||||
|
||||
// Duplicated from PageDown converter
|
||||
function unescapeSpecialChars(text) {
|
||||
// Swap back in all the special characters we've hidden.
|
||||
text = text.replace(/~E(\d+)E/g, function(wholeMatch, m1) {
|
||||
var charCodeToReplace = parseInt(m1);
|
||||
return String.fromCharCode(charCodeToReplace);
|
||||
});
|
||||
return text;
|
||||
}
|
||||
|
||||
function slugify(text) {
|
||||
return text.toLowerCase()
|
||||
.replace(/\s+/g, '-') // Replace spaces with -
|
||||
.replace(/[^\w\-]+/g, '') // Remove all non-word chars
|
||||
.replace(/\-\-+/g, '-') // Replace multiple - with single -
|
||||
.replace(/^-+/, '') // Trim - from start of text
|
||||
.replace(/-+$/, ''); // Trim - from end of text
|
||||
}
|
||||
|
||||
/*****************************************************************************
|
||||
* Markdown.Extra *
|
||||
****************************************************************************/
|
||||
|
||||
Markdown.Extra = function() {
|
||||
// For converting internal markdown (in tables for instance).
|
||||
// This is necessary since these methods are meant to be called as
|
||||
// preConversion hooks, and the Markdown converter passed to init()
|
||||
// won't convert any markdown contained in the html tags we return.
|
||||
this.converter = null;
|
||||
|
||||
// Stores html blocks we generate in hooks so that
|
||||
// they're not destroyed if the user is using a sanitizing converter
|
||||
this.hashBlocks = [];
|
||||
|
||||
// Stores footnotes
|
||||
this.footnotes = {};
|
||||
this.usedFootnotes = [];
|
||||
|
||||
// Special attribute blocks for fenced code blocks and headers enabled.
|
||||
this.attributeBlocks = false;
|
||||
|
||||
// Fenced code block options
|
||||
this.googleCodePrettify = false;
|
||||
this.highlightJs = false;
|
||||
|
||||
// Table options
|
||||
this.tableClass = '';
|
||||
|
||||
this.tabWidth = 4;
|
||||
};
|
||||
|
||||
Markdown.Extra.init = function(converter, options) {
|
||||
// Each call to init creates a new instance of Markdown.Extra so it's
|
||||
// safe to have multiple converters, with different options, on a single page
|
||||
var extra = new Markdown.Extra();
|
||||
var postNormalizationTransformations = [];
|
||||
var preBlockGamutTransformations = [];
|
||||
var postSpanGamutTransformations = [];
|
||||
var postConversionTransformations = ["unHashExtraBlocks"];
|
||||
|
||||
options = options || {};
|
||||
options.extensions = options.extensions || ["all"];
|
||||
if (contains(options.extensions, "all")) {
|
||||
options.extensions = ["tables", "fenced_code_gfm", "def_list", "attr_list", "footnotes", "smartypants", "strikethrough", "newlines"];
|
||||
}
|
||||
preBlockGamutTransformations.push("wrapHeaders");
|
||||
if (contains(options.extensions, "attr_list")) {
|
||||
postNormalizationTransformations.push("hashFcbAttributeBlocks");
|
||||
preBlockGamutTransformations.push("hashHeaderAttributeBlocks");
|
||||
postConversionTransformations.push("applyAttributeBlocks");
|
||||
extra.attributeBlocks = true;
|
||||
}
|
||||
if (contains(options.extensions, "fenced_code_gfm")) {
|
||||
// This step will convert fcb inside list items and blockquotes
|
||||
preBlockGamutTransformations.push("fencedCodeBlocks");
|
||||
// This extra step is to prevent html blocks hashing and link definition/footnotes stripping inside fcb
|
||||
postNormalizationTransformations.push("fencedCodeBlocks");
|
||||
}
|
||||
if (contains(options.extensions, "tables")) {
|
||||
preBlockGamutTransformations.push("tables");
|
||||
}
|
||||
if (contains(options.extensions, "def_list")) {
|
||||
preBlockGamutTransformations.push("definitionLists");
|
||||
}
|
||||
if (contains(options.extensions, "footnotes")) {
|
||||
postNormalizationTransformations.push("stripFootnoteDefinitions");
|
||||
preBlockGamutTransformations.push("doFootnotes");
|
||||
postConversionTransformations.push("printFootnotes");
|
||||
}
|
||||
if (contains(options.extensions, "smartypants")) {
|
||||
postConversionTransformations.push("runSmartyPants");
|
||||
}
|
||||
if (contains(options.extensions, "strikethrough")) {
|
||||
postSpanGamutTransformations.push("strikethrough");
|
||||
}
|
||||
if (contains(options.extensions, "newlines")) {
|
||||
postSpanGamutTransformations.push("newlines");
|
||||
}
|
||||
|
||||
converter.hooks.chain("postNormalization", function(text) {
|
||||
return extra.doTransform(postNormalizationTransformations, text) + '\n';
|
||||
});
|
||||
|
||||
converter.hooks.chain("preBlockGamut", function(text, blockGamutHookCallback) {
|
||||
// Keep a reference to the block gamut callback to run recursively
|
||||
extra.blockGamutHookCallback = blockGamutHookCallback;
|
||||
text = processEscapesStep1(text);
|
||||
text = extra.doTransform(preBlockGamutTransformations, text) + '\n';
|
||||
text = processEscapesStep2(text);
|
||||
return text;
|
||||
});
|
||||
|
||||
converter.hooks.chain("postSpanGamut", function(text) {
|
||||
return extra.doTransform(postSpanGamutTransformations, text);
|
||||
});
|
||||
|
||||
// Keep a reference to the hook chain running before doPostConversion to apply on hashed extra blocks
|
||||
extra.previousPostConversion = converter.hooks.postConversion;
|
||||
converter.hooks.chain("postConversion", function(text) {
|
||||
text = extra.doTransform(postConversionTransformations, text);
|
||||
// Clear state vars that may use unnecessary memory
|
||||
extra.hashBlocks = [];
|
||||
extra.footnotes = {};
|
||||
extra.usedFootnotes = [];
|
||||
return text;
|
||||
});
|
||||
|
||||
if ("highlighter" in options) {
|
||||
extra.googleCodePrettify = options.highlighter === 'prettify';
|
||||
extra.highlightJs = options.highlighter === 'highlight';
|
||||
}
|
||||
|
||||
if ("table_class" in options) {
|
||||
extra.tableClass = options.table_class;
|
||||
}
|
||||
|
||||
extra.converter = converter;
|
||||
|
||||
// Caller usually won't need this, but it's handy for testing.
|
||||
return extra;
|
||||
};
|
||||
|
||||
// Do transformations
|
||||
Markdown.Extra.prototype.doTransform = function(transformations, text) {
|
||||
for(var i = 0; i < transformations.length; i++)
|
||||
text = this[transformations[i]](text);
|
||||
return text;
|
||||
};
|
||||
|
||||
// Return a placeholder containing a key, which is the block's index in the
|
||||
// hashBlocks array. We wrap our output in a <p> tag here so Pagedown won't.
|
||||
Markdown.Extra.prototype.hashExtraBlock = function(block) {
|
||||
return '\n<p>~X' + (this.hashBlocks.push(block) - 1) + 'X</p>\n';
|
||||
};
|
||||
Markdown.Extra.prototype.hashExtraInline = function(block) {
|
||||
return '~X' + (this.hashBlocks.push(block) - 1) + 'X';
|
||||
};
|
||||
|
||||
// Replace placeholder blocks in `text` with their corresponding
|
||||
// html blocks in the hashBlocks array.
|
||||
Markdown.Extra.prototype.unHashExtraBlocks = function(text) {
|
||||
var self = this;
|
||||
function recursiveUnHash() {
|
||||
var hasHash = false;
|
||||
text = text.replace(/(?:<p>)?~X(\d+)X(?:<\/p>)?/g, function(wholeMatch, m1) {
|
||||
hasHash = true;
|
||||
var key = parseInt(m1, 10);
|
||||
return self.hashBlocks[key];
|
||||
});
|
||||
if(hasHash === true) {
|
||||
recursiveUnHash();
|
||||
}
|
||||
}
|
||||
recursiveUnHash();
|
||||
return text;
|
||||
};
|
||||
|
||||
// Wrap headers to make sure they won't be in def lists
|
||||
Markdown.Extra.prototype.wrapHeaders = function(text) {
|
||||
function wrap(text) {
|
||||
return '\n' + text + '\n';
|
||||
}
|
||||
text = text.replace(/^.+[ \t]*\n=+[ \t]*\n+/gm, wrap);
|
||||
text = text.replace(/^.+[ \t]*\n-+[ \t]*\n+/gm, wrap);
|
||||
text = text.replace(/^\#{1,6}[ \t]*.+?[ \t]*\#*\n+/gm, wrap);
|
||||
return text;
|
||||
};
|
||||
|
||||
|
||||
/******************************************************************
|
||||
* Attribute Blocks *
|
||||
*****************************************************************/
|
||||
|
||||
// TODO: use sentinels. Should we just add/remove them in doConversion?
|
||||
// TODO: better matches for id / class attributes
|
||||
var attrBlock = "\\{[ \\t]*((?:[#.][-_:a-zA-Z0-9]+[ \\t]*)+)\\}";
|
||||
var hdrAttributesA = new RegExp("^(#{1,6}.*#{0,6})[ \\t]+" + attrBlock + "[ \\t]*(?:\\n|0x03)", "gm");
|
||||
var hdrAttributesB = new RegExp("^(.*)[ \\t]+" + attrBlock + "[ \\t]*\\n" +
|
||||
"(?=[\\-|=]+\\s*(?:\\n|0x03))", "gm"); // underline lookahead
|
||||
var fcbAttributes = new RegExp("^(```[^`\\n]*)[ \\t]+" + attrBlock + "[ \\t]*\\n" +
|
||||
"(?=([\\s\\S]*?)\\n```[ \\t]*(\\n|0x03))", "gm");
|
||||
|
||||
// Extract headers attribute blocks, move them above the element they will be
|
||||
// applied to, and hash them for later.
|
||||
Markdown.Extra.prototype.hashHeaderAttributeBlocks = function(text) {
|
||||
|
||||
var self = this;
|
||||
function attributeCallback(wholeMatch, pre, attr) {
|
||||
return '<p>~XX' + (self.hashBlocks.push(attr) - 1) + 'XX</p>\n' + pre + "\n";
|
||||
}
|
||||
|
||||
text = text.replace(hdrAttributesA, attributeCallback); // ## headers
|
||||
text = text.replace(hdrAttributesB, attributeCallback); // underline headers
|
||||
return text;
|
||||
};
|
||||
|
||||
// Extract FCB attribute blocks, move them above the element they will be
|
||||
// applied to, and hash them for later.
|
||||
Markdown.Extra.prototype.hashFcbAttributeBlocks = function(text) {
|
||||
// TODO: use sentinels. Should we just add/remove them in doConversion?
|
||||
// TODO: better matches for id / class attributes
|
||||
|
||||
var self = this;
|
||||
function attributeCallback(wholeMatch, pre, attr) {
|
||||
return '<p>~XX' + (self.hashBlocks.push(attr) - 1) + 'XX</p>\n' + pre + "\n";
|
||||
}
|
||||
|
||||
return text.replace(fcbAttributes, attributeCallback);
|
||||
};
|
||||
|
||||
Markdown.Extra.prototype.applyAttributeBlocks = function(text) {
|
||||
var self = this;
|
||||
var blockRe = new RegExp('<p>~XX(\\d+)XX</p>[\\s]*' +
|
||||
'(?:<(h[1-6]|pre)(?: +class="(\\S+)")?(>[\\s\\S]*?</\\2>))', "gm");
|
||||
text = text.replace(blockRe, function(wholeMatch, k, tag, cls, rest) {
|
||||
if (!tag) // no following header or fenced code block.
|
||||
return '';
|
||||
|
||||
// get attributes list from hash
|
||||
var key = parseInt(k, 10);
|
||||
var attributes = self.hashBlocks[key];
|
||||
|
||||
// get id
|
||||
var id = attributes.match(/#[^\s#.]+/g) || [];
|
||||
var idStr = id[0] ? ' id="' + id[0].substr(1, id[0].length - 1) + '"' : '';
|
||||
|
||||
// get classes and merge with existing classes
|
||||
var classes = attributes.match(/\.[^\s#.]+/g) || [];
|
||||
for (var i = 0; i < classes.length; i++) // Remove leading dot
|
||||
classes[i] = classes[i].substr(1, classes[i].length - 1);
|
||||
|
||||
var classStr = '';
|
||||
if (cls)
|
||||
classes = union(classes, [cls]);
|
||||
|
||||
if (classes.length > 0)
|
||||
classStr = ' class="' + classes.join(' ') + '"';
|
||||
|
||||
return "<" + tag + idStr + classStr + rest;
|
||||
});
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
/******************************************************************
|
||||
* Tables *
|
||||
*****************************************************************/
|
||||
|
||||
// Find and convert Markdown Extra tables into html.
|
||||
Markdown.Extra.prototype.tables = function(text) {
|
||||
var self = this;
|
||||
|
||||
var leadingPipe = new RegExp(
|
||||
['^' ,
|
||||
'[ ]{0,3}' , // Allowed whitespace
|
||||
'[|]' , // Initial pipe
|
||||
'(.+)\\n' , // $1: Header Row
|
||||
|
||||
'[ ]{0,3}' , // Allowed whitespace
|
||||
'[|]([ ]*[-:]+[-| :]*)\\n' , // $2: Separator
|
||||
|
||||
'(' , // $3: Table Body
|
||||
'(?:[ ]*[|].*\\n?)*' , // Table rows
|
||||
')',
|
||||
'(?:\\n|$)' // Stop at final newline
|
||||
].join(''),
|
||||
'gm'
|
||||
);
|
||||
|
||||
var noLeadingPipe = new RegExp(
|
||||
['^' ,
|
||||
'[ ]{0,3}' , // Allowed whitespace
|
||||
'(\\S.*[|].*)\\n' , // $1: Header Row
|
||||
|
||||
'[ ]{0,3}' , // Allowed whitespace
|
||||
'([-:]+[ ]*[|][-| :]*)\\n' , // $2: Separator
|
||||
|
||||
'(' , // $3: Table Body
|
||||
'(?:.*[|].*\\n?)*' , // Table rows
|
||||
')' ,
|
||||
'(?:\\n|$)' // Stop at final newline
|
||||
].join(''),
|
||||
'gm'
|
||||
);
|
||||
|
||||
text = text.replace(leadingPipe, doTable);
|
||||
text = text.replace(noLeadingPipe, doTable);
|
||||
|
||||
// $1 = header, $2 = separator, $3 = body
|
||||
function doTable(match, header, separator, body, offset, string) {
|
||||
// remove any leading pipes and whitespace
|
||||
header = header.replace(/^ *[|]/m, '');
|
||||
separator = separator.replace(/^ *[|]/m, '');
|
||||
body = body.replace(/^ *[|]/gm, '');
|
||||
|
||||
// remove trailing pipes and whitespace
|
||||
header = header.replace(/[|] *$/m, '');
|
||||
separator = separator.replace(/[|] *$/m, '');
|
||||
body = body.replace(/[|] *$/gm, '');
|
||||
|
||||
// determine column alignments
|
||||
var alignspecs = separator.split(/ *[|] */);
|
||||
var align = [];
|
||||
for (var i = 0; i < alignspecs.length; i++) {
|
||||
var spec = alignspecs[i];
|
||||
if (spec.match(/^ *-+: *$/m))
|
||||
align[i] = ' align="right"';
|
||||
else if (spec.match(/^ *:-+: *$/m))
|
||||
align[i] = ' align="center"';
|
||||
else if (spec.match(/^ *:-+ *$/m))
|
||||
align[i] = ' align="left"';
|
||||
else align[i] = '';
|
||||
}
|
||||
|
||||
// TODO: parse spans in header and rows before splitting, so that pipes
|
||||
// inside of tags are not interpreted as separators
|
||||
var headers = header.split(/ *[|] */);
|
||||
var colCount = headers.length;
|
||||
|
||||
// build html
|
||||
var cls = self.tableClass ? ' class="' + self.tableClass + '"' : '';
|
||||
var html = ['<table', cls, '>\n', '<thead>\n', '<tr>\n'].join('');
|
||||
|
||||
// build column headers.
|
||||
for (i = 0; i < colCount; i++) {
|
||||
var headerHtml = convertSpans(trim(headers[i]), self);
|
||||
html += [" <th", align[i], ">", headerHtml, "</th>\n"].join('');
|
||||
}
|
||||
html += "</tr>\n</thead>\n";
|
||||
|
||||
// build rows
|
||||
var rows = body.split('\n');
|
||||
for (i = 0; i < rows.length; i++) {
|
||||
if (rows[i].match(/^\s*$/)) // can apply to final row
|
||||
continue;
|
||||
|
||||
// ensure number of rowCells matches colCount
|
||||
var rowCells = rows[i].split(/ *[|] */);
|
||||
var lenDiff = colCount - rowCells.length;
|
||||
for (var j = 0; j < lenDiff; j++)
|
||||
rowCells.push('');
|
||||
|
||||
html += "<tr>\n";
|
||||
for (j = 0; j < colCount; j++) {
|
||||
var colHtml = convertSpans(trim(rowCells[j]), self);
|
||||
html += [" <td", align[j], ">", colHtml, "</td>\n"].join('');
|
||||
}
|
||||
html += "</tr>\n";
|
||||
}
|
||||
|
||||
html += "</table>\n";
|
||||
|
||||
// replace html with placeholder until postConversion step
|
||||
return self.hashExtraBlock(html);
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
|
||||
/******************************************************************
|
||||
* Footnotes *
|
||||
*****************************************************************/
|
||||
|
||||
// Strip footnote, store in hashes.
|
||||
Markdown.Extra.prototype.stripFootnoteDefinitions = function(text) {
|
||||
var self = this;
|
||||
|
||||
text = text.replace(
|
||||
/\n[ ]{0,3}\[\^(.+?)\]\:[ \t]*\n?([\s\S]*?)\n{1,2}((?=\n[ ]{0,3}\S)|$)/g,
|
||||
function(wholeMatch, m1, m2) {
|
||||
m1 = slugify(m1);
|
||||
m2 += "\n";
|
||||
m2 = m2.replace(/^[ ]{0,3}/g, "");
|
||||
self.footnotes[m1] = m2;
|
||||
return "\n";
|
||||
});
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
|
||||
// Find and convert footnotes references.
|
||||
Markdown.Extra.prototype.doFootnotes = function(text) {
|
||||
var self = this;
|
||||
if(self.isConvertingFootnote === true) {
|
||||
return text;
|
||||
}
|
||||
|
||||
var footnoteCounter = 0;
|
||||
text = text.replace(/\[\^(.+?)\]/g, function(wholeMatch, m1) {
|
||||
var id = slugify(m1);
|
||||
var footnote = self.footnotes[id];
|
||||
if (footnote === undefined) {
|
||||
return wholeMatch;
|
||||
}
|
||||
footnoteCounter++;
|
||||
self.usedFootnotes.push(id);
|
||||
var html = '<a href="#fn:' + id + '" id="fnref:' + id
|
||||
+ '" title="See footnote" class="footnote">' + footnoteCounter
|
||||
+ '</a>';
|
||||
return self.hashExtraInline(html);
|
||||
});
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
// Print footnotes at the end of the document
|
||||
Markdown.Extra.prototype.printFootnotes = function(text) {
|
||||
var self = this;
|
||||
|
||||
if (self.usedFootnotes.length === 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
text += '\n\n<div class="footnotes">\n<hr>\n<ol>\n\n';
|
||||
for(var i=0; i<self.usedFootnotes.length; i++) {
|
||||
var id = self.usedFootnotes[i];
|
||||
var footnote = self.footnotes[id];
|
||||
self.isConvertingFootnote = true;
|
||||
var formattedfootnote = convertSpans(footnote, self);
|
||||
delete self.isConvertingFootnote;
|
||||
text += '<li id="fn:'
|
||||
+ id
|
||||
+ '">'
|
||||
+ formattedfootnote
|
||||
+ ' <a href="#fnref:'
|
||||
+ id
|
||||
+ '" title="Return to article" class="reversefootnote">↩</a></li>\n\n';
|
||||
}
|
||||
text += '</ol>\n</div>';
|
||||
return text;
|
||||
};
|
||||
|
||||
|
||||
/******************************************************************
|
||||
* Fenced Code Blocks (gfm) *
|
||||
******************************************************************/
|
||||
|
||||
// Find and convert gfm-inspired fenced code blocks into html.
|
||||
Markdown.Extra.prototype.fencedCodeBlocks = function(text) {
|
||||
function encodeCode(code) {
|
||||
code = code.replace(/&/g, "&");
|
||||
code = code.replace(/</g, "<");
|
||||
code = code.replace(/>/g, ">");
|
||||
// These were escaped by PageDown before postNormalization
|
||||
code = code.replace(/~D/g, "$$");
|
||||
code = code.replace(/~T/g, "~");
|
||||
return code;
|
||||
}
|
||||
|
||||
var self = this;
|
||||
text = text.replace(/(?:^|\n)```([^`\n]*)\n([\s\S]*?)\n```[ \t]*(?=\n)/g, function(match, m1, m2) {
|
||||
var language = trim(m1), codeblock = m2;
|
||||
|
||||
// adhere to specified options
|
||||
var preclass = self.googleCodePrettify ? ' class="prettyprint"' : '';
|
||||
var codeclass = '';
|
||||
if (language) {
|
||||
if (self.googleCodePrettify || self.highlightJs) {
|
||||
// use html5 language- class names. supported by both prettify and highlight.js
|
||||
codeclass = ' class="language-' + language + '"';
|
||||
} else {
|
||||
codeclass = ' class="' + language + '"';
|
||||
}
|
||||
}
|
||||
|
||||
var html = ['<pre', preclass, '><code', codeclass, '>',
|
||||
encodeCode(codeblock), '</code></pre>'].join('');
|
||||
|
||||
// replace codeblock with placeholder until postConversion step
|
||||
return self.hashExtraBlock(html);
|
||||
});
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
|
||||
/******************************************************************
|
||||
* SmartyPants *
|
||||
******************************************************************/
|
||||
|
||||
Markdown.Extra.prototype.educatePants = function(text) {
|
||||
var self = this;
|
||||
var result = '';
|
||||
var blockOffset = 0;
|
||||
// Here we parse HTML in a very bad manner
|
||||
text.replace(/(?:<!--[\s\S]*?-->)|(<)([a-zA-Z1-6]+)([^\n]*?>)([\s\S]*?)(<\/\2>)/g, function(wholeMatch, m1, m2, m3, m4, m5, offset) {
|
||||
var token = text.substring(blockOffset, offset);
|
||||
result += self.applyPants(token);
|
||||
self.smartyPantsLastChar = result.substring(result.length - 1);
|
||||
blockOffset = offset + wholeMatch.length;
|
||||
if(!m1) {
|
||||
// Skip commentary
|
||||
result += wholeMatch;
|
||||
return;
|
||||
}
|
||||
// Skip special tags
|
||||
if(!/code|kbd|pre|script|noscript|iframe|math|ins|del|pre/i.test(m2)) {
|
||||
m4 = self.educatePants(m4);
|
||||
}
|
||||
else {
|
||||
self.smartyPantsLastChar = m4.substring(m4.length - 1);
|
||||
}
|
||||
result += m1 + m2 + m3 + m4 + m5;
|
||||
});
|
||||
var lastToken = text.substring(blockOffset);
|
||||
result += self.applyPants(lastToken);
|
||||
self.smartyPantsLastChar = result.substring(result.length - 1);
|
||||
return result;
|
||||
};
|
||||
|
||||
function revertPants(wholeMatch, m1) {
|
||||
var blockText = m1;
|
||||
blockText = blockText.replace(/&\#8220;/g, "\"");
|
||||
blockText = blockText.replace(/&\#8221;/g, "\"");
|
||||
blockText = blockText.replace(/&\#8216;/g, "'");
|
||||
blockText = blockText.replace(/&\#8217;/g, "'");
|
||||
blockText = blockText.replace(/&\#8212;/g, "---");
|
||||
blockText = blockText.replace(/&\#8211;/g, "--");
|
||||
blockText = blockText.replace(/&\#8230;/g, "...");
|
||||
return blockText;
|
||||
}
|
||||
|
||||
Markdown.Extra.prototype.applyPants = function(text) {
|
||||
// Dashes
|
||||
text = text.replace(/---/g, "—").replace(/--/g, "–");
|
||||
// Ellipses
|
||||
text = text.replace(/\.\.\./g, "…").replace(/\.\s\.\s\./g, "…");
|
||||
// Backticks
|
||||
text = text.replace(/``/g, "“").replace (/''/g, "”");
|
||||
|
||||
if(/^'$/.test(text)) {
|
||||
// Special case: single-character ' token
|
||||
if(/\S/.test(this.smartyPantsLastChar)) {
|
||||
return "’";
|
||||
}
|
||||
return "‘";
|
||||
}
|
||||
if(/^"$/.test(text)) {
|
||||
// Special case: single-character " token
|
||||
if(/\S/.test(this.smartyPantsLastChar)) {
|
||||
return "”";
|
||||
}
|
||||
return "“";
|
||||
}
|
||||
|
||||
// Special case if the very first character is a quote
|
||||
// followed by punctuation at a non-word-break. Close the quotes by brute force:
|
||||
text = text.replace (/^'(?=[!"#\$\%'()*+,\-.\/:;<=>?\@\[\\]\^_`{|}~]\B)/, "’");
|
||||
text = text.replace (/^"(?=[!"#\$\%'()*+,\-.\/:;<=>?\@\[\\]\^_`{|}~]\B)/, "”");
|
||||
|
||||
// Special case for double sets of quotes, e.g.:
|
||||
// <p>He said, "'Quoted' words in a larger quote."</p>
|
||||
text = text.replace(/"'(?=\w)/g, "“‘");
|
||||
text = text.replace(/'"(?=\w)/g, "‘“");
|
||||
|
||||
// Special case for decade abbreviations (the '80s):
|
||||
text = text.replace(/'(?=\d{2}s)/g, "’");
|
||||
|
||||
// Get most opening single quotes:
|
||||
text = text.replace(/(\s| |--|&[mn]dash;|&\#8211;|&\#8212;|&\#x201[34];)'(?=\w)/g, "$1‘");
|
||||
|
||||
// Single closing quotes:
|
||||
text = text.replace(/([^\s\[\{\(\-])'/g, "$1’");
|
||||
text = text.replace(/'(?=\s|s\b)/g, "’");
|
||||
|
||||
// Any remaining single quotes should be opening ones:
|
||||
text = text.replace(/'/g, "‘");
|
||||
|
||||
// Get most opening double quotes:
|
||||
text = text.replace(/(\s| |--|&[mn]dash;|&\#8211;|&\#8212;|&\#x201[34];)"(?=\w)/g, "$1“");
|
||||
|
||||
// Double closing quotes:
|
||||
text = text.replace(/([^\s\[\{\(\-])"/g, "$1”");
|
||||
text = text.replace(/"(?=\s)/g, "”");
|
||||
|
||||
// Any remaining quotes should be opening ones.
|
||||
text = text.replace(/"/ig, "“");
|
||||
return text;
|
||||
};
|
||||
|
||||
// Find and convert markdown extra definition lists into html.
|
||||
Markdown.Extra.prototype.runSmartyPants = function(text) {
|
||||
this.smartyPantsLastChar = '';
|
||||
text = this.educatePants(text);
|
||||
// Clean everything inside html tags (some of them may have been converted due to our rough html parsing)
|
||||
text = text.replace(/(<([a-zA-Z1-6]+)\b([^\n>]*?)(\/)?>)/g, revertPants);
|
||||
return text;
|
||||
};
|
||||
|
||||
/******************************************************************
|
||||
* Definition Lists *
|
||||
******************************************************************/
|
||||
|
||||
// Find and convert markdown extra definition lists into html.
|
||||
Markdown.Extra.prototype.definitionLists = function(text) {
|
||||
var wholeList = new RegExp(
|
||||
['(\\x02\\n?|\\n\\n)' ,
|
||||
'(?:' ,
|
||||
'(' , // $1 = whole list
|
||||
'(' , // $2
|
||||
'[ ]{0,3}' ,
|
||||
'((?:[ \\t]*\\S.*\\n)+)', // $3 = defined term
|
||||
'\\n?' ,
|
||||
'[ ]{0,3}:[ ]+' , // colon starting definition
|
||||
')' ,
|
||||
'([\\s\\S]+?)' ,
|
||||
'(' , // $4
|
||||
'(?=\\0x03)' , // \z
|
||||
'|' ,
|
||||
'(?=' ,
|
||||
'\\n{2,}' ,
|
||||
'(?=\\S)' ,
|
||||
'(?!' , // Negative lookahead for another term
|
||||
'[ ]{0,3}' ,
|
||||
'(?:\\S.*\\n)+?' , // defined term
|
||||
'\\n?' ,
|
||||
'[ ]{0,3}:[ ]+' , // colon starting definition
|
||||
')' ,
|
||||
'(?!' , // Negative lookahead for another definition
|
||||
'[ ]{0,3}:[ ]+' , // colon starting definition
|
||||
')' ,
|
||||
')' ,
|
||||
')' ,
|
||||
')' ,
|
||||
')'
|
||||
].join(''),
|
||||
'gm'
|
||||
);
|
||||
|
||||
var self = this;
|
||||
text = addAnchors(text);
|
||||
|
||||
text = text.replace(wholeList, function(match, pre, list) {
|
||||
var result = trim(self.processDefListItems(list));
|
||||
result = "<dl>\n" + result + "\n</dl>";
|
||||
return pre + self.hashExtraBlock(result) + "\n\n";
|
||||
});
|
||||
|
||||
return removeAnchors(text);
|
||||
};
|
||||
|
||||
// Process the contents of a single definition list, splitting it
|
||||
// into individual term and definition list items.
|
||||
Markdown.Extra.prototype.processDefListItems = function(listStr) {
|
||||
var self = this;
|
||||
|
||||
var dt = new RegExp(
|
||||
['(\\x02\\n?|\\n\\n+)' , // leading line
|
||||
'(' , // definition terms = $1
|
||||
'[ ]{0,3}' , // leading whitespace
|
||||
'(?![:][ ]|[ ])' , // negative lookahead for a definition
|
||||
// mark (colon) or more whitespace
|
||||
'(?:\\S.*\\n)+?' , // actual term (not whitespace)
|
||||
')' ,
|
||||
'(?=\\n?[ ]{0,3}:[ ])' // lookahead for following line feed
|
||||
].join(''), // with a definition mark
|
||||
'gm'
|
||||
);
|
||||
|
||||
var dd = new RegExp(
|
||||
['\\n(\\n+)?' , // leading line = $1
|
||||
'(' , // marker space = $2
|
||||
'[ ]{0,3}' , // whitespace before colon
|
||||
'[:][ ]+' , // definition mark (colon)
|
||||
')' ,
|
||||
'([\\s\\S]+?)' , // definition text = $3
|
||||
'(?=\\n*' , // stop at next definition mark,
|
||||
'(?:' , // next term or end of text
|
||||
'\\n[ ]{0,3}[:][ ]|' ,
|
||||
'<dt>|\\x03' , // \z
|
||||
')' ,
|
||||
')'
|
||||
].join(''),
|
||||
'gm'
|
||||
);
|
||||
|
||||
listStr = addAnchors(listStr);
|
||||
// trim trailing blank lines:
|
||||
listStr = listStr.replace(/\n{2,}(?=\\x03)/, "\n");
|
||||
|
||||
// Process definition terms.
|
||||
listStr = listStr.replace(dt, function(match, pre, termsStr) {
|
||||
var terms = trim(termsStr).split("\n");
|
||||
var text = '';
|
||||
for (var i = 0; i < terms.length; i++) {
|
||||
var term = terms[i];
|
||||
// process spans inside dt
|
||||
term = convertSpans(trim(term), self);
|
||||
text += "\n<dt>" + term + "</dt>";
|
||||
}
|
||||
return text + "\n";
|
||||
});
|
||||
|
||||
// Process actual definitions.
|
||||
listStr = listStr.replace(dd, function(match, leadingLine, markerSpace, def) {
|
||||
if (leadingLine || def.match(/\n{2,}/)) {
|
||||
// replace marker with the appropriate whitespace indentation
|
||||
def = Array(markerSpace.length + 1).join(' ') + def;
|
||||
// process markdown inside definition
|
||||
// TODO?: currently doesn't apply extensions
|
||||
def = outdent(def) + "\n\n";
|
||||
def = "\n" + convertAll(def, self) + "\n";
|
||||
} else {
|
||||
// convert span-level markdown inside definition
|
||||
def = rtrim(def);
|
||||
def = convertSpans(outdent(def), self);
|
||||
}
|
||||
|
||||
return "\n<dd>" + def + "</dd>\n";
|
||||
});
|
||||
|
||||
return removeAnchors(listStr);
|
||||
};
|
||||
|
||||
|
||||
/***********************************************************
|
||||
* Strikethrough *
|
||||
************************************************************/
|
||||
|
||||
Markdown.Extra.prototype.strikethrough = function(text) {
|
||||
// Pretty much duplicated from _DoItalicsAndBold
|
||||
return text.replace(/([\W_]|^)~T~T(?=\S)([^\r]*?\S[\*_]*)~T~T([\W_]|$)/g,
|
||||
"$1<del>$2</del>$3");
|
||||
};
|
||||
|
||||
|
||||
/***********************************************************
|
||||
* New lines *
|
||||
************************************************************/
|
||||
|
||||
Markdown.Extra.prototype.newlines = function(text) {
|
||||
// We have to ignore already converted newlines and line breaks in sub-list items
|
||||
return text.replace(/(<(?:br|\/li)>)?\n/g, function(wholeMatch, previousTag) {
|
||||
return previousTag ? wholeMatch : " <br>\n";
|
||||
});
|
||||
};
|
||||
|
||||
})();
|
||||
|
@@ -32,7 +32,7 @@ var DocumentTitleAPI = {
|
||||
};
|
||||
|
||||
|
||||
/* Status Bar */
|
||||
/* Status Bar * DEPRECATED * USE TOASTR INSTEAD */
|
||||
function statusBarClear(delay_class, delay_html){
|
||||
var statusBar = $("#status-bar");
|
||||
|
||||
@@ -54,6 +54,7 @@ function statusBarClear(delay_class, delay_html){
|
||||
}
|
||||
}
|
||||
|
||||
/* Status Bar * DEPRECATED - USE TOASTR INSTEAD * */
|
||||
function statusBarSet(classes, html, icon_name, time){
|
||||
/* Utility to notify the user by temporarily flashing text on the project header
|
||||
Usage:
|
||||
|
@@ -66,12 +66,9 @@ function containerResizeY(window_height){
|
||||
|
||||
var project_container = document.getElementById('project-container');
|
||||
var container_offset = project_container.offsetTop;
|
||||
var nav_header_height = $('#project_nav-header').height();
|
||||
var container_height = window_height - container_offset.top;
|
||||
var container_height_wheader = window_height - container_offset.top - nav_header_height;
|
||||
var window_height_minus_nav = window_height - nav_header_height - 1; // 1 is border width
|
||||
|
||||
$('#project_context-header').width($('#project_context-container').width());
|
||||
var container_height_wheader = window_height - container_offset;
|
||||
var window_height_minus_nav = window_height - container_offset;
|
||||
|
||||
if ($(window).width() > 768) {
|
||||
$('#project-container').css(
|
||||
@@ -79,13 +76,14 @@ function containerResizeY(window_height){
|
||||
'height': window_height_minus_nav + 'px'}
|
||||
);
|
||||
|
||||
$('#project_nav-container, #project_tree, .project_split').css(
|
||||
{'max-height': (window_height_minus_nav - 50) + 'px',
|
||||
'height': (window_height_minus_nav - 50) + 'px'}
|
||||
$('#project_nav-container, #project_tree').css(
|
||||
{'max-height': (window_height_minus_nav) + 'px',
|
||||
'height': (window_height_minus_nav) + 'px'}
|
||||
);
|
||||
|
||||
if (container_height > parseInt($('#project-container').css("min-height"))) {
|
||||
if (typeof projectTree !== "undefined"){
|
||||
|
||||
$(projectTree).css(
|
||||
{'max-height': container_height_wheader + 'px',
|
||||
'height': container_height_wheader + 'px'}
|
||||
|
202
src/scripts/video_plugins.js
Normal file
202
src/scripts/video_plugins.js
Normal file
@@ -0,0 +1,202 @@
|
||||
/* Video.JS plugin for keeping track of user's viewing progress.
|
||||
Also registers the analytics plugin.
|
||||
|
||||
Progress is reported after a number of seconds or a percentage
|
||||
of the duration of the video, whichever comes first.
|
||||
|
||||
Example usage:
|
||||
|
||||
videojs(videoPlayerElement, options).ready(function() {
|
||||
let report_url = '{{ url_for("users_api.set_video_progress", video_id=node._id) }}';
|
||||
this.progressPlugin({'report_url': report_url});
|
||||
});
|
||||
|
||||
*/
|
||||
|
||||
// Report after progressing this many seconds video-time.
|
||||
let PROGRESS_REPORT_INTERVAL_SEC = 30;
|
||||
|
||||
// Report after progressing this percentage of the entire video (scale 0-100).
|
||||
let PROGRESS_REPORT_INTERVAL_PERC = 10;
|
||||
|
||||
// Don't report within this many milliseconds of wall-clock time of the previous report.
|
||||
let PROGRESS_RELAXING_TIME_MSEC = 500;
|
||||
|
||||
|
||||
var Plugin = videojs.getPlugin('plugin');
|
||||
var VideoProgressPlugin = videojs.extend(Plugin, {
|
||||
constructor: function(player, options) {
|
||||
Plugin.call(this, player, options);
|
||||
|
||||
this.last_wallclock_time_ms = 0;
|
||||
this.last_inspected_progress_in_sec = 0;
|
||||
this.last_reported_progress_in_sec = 0;
|
||||
this.last_reported_progress_in_perc = 0;
|
||||
this.report_url = options.report_url;
|
||||
this.fetch_progress_url = options.fetch_progress_url;
|
||||
this.reported_error = false;
|
||||
this.reported_looping = false;
|
||||
|
||||
if (typeof this.report_url === 'undefined' || !this.report_url) {
|
||||
/* If we can't report anything, don't bother registering event handlers. */
|
||||
videojs.log('VideoProgressPlugin: no report_url option given. Not storing video progress.');
|
||||
} else {
|
||||
/* Those events will have 'this' bound to the player,
|
||||
* which is why we explicitly re-bind to 'this''. */
|
||||
player.on('timeupdate', this.on_timeupdate.bind(this));
|
||||
player.on('pause', this.on_pause.bind(this));
|
||||
}
|
||||
|
||||
if (typeof this.fetch_progress_url === 'undefined' || !this.fetch_progress_url) {
|
||||
/* If we can't report anything, don't bother registering event handlers. */
|
||||
videojs.log('VideoProgressPlugin: no fetch_progress_url option given. Not restoring video progress.');
|
||||
} else {
|
||||
this.resume_playback();
|
||||
}
|
||||
},
|
||||
|
||||
resume_playback: function() {
|
||||
let on_done = function(progress, status, xhr) {
|
||||
/* 'progress' is an object like:
|
||||
{"progress_in_sec": 3,
|
||||
"progress_in_percent": 51,
|
||||
"last_watched": "Fri, 31 Aug 2018 13:53:06 GMT",
|
||||
"done": true}
|
||||
*/
|
||||
switch (xhr.status) {
|
||||
case 204: return; // no info found.
|
||||
case 200:
|
||||
/* Don't do anything when the progress is at 100%.
|
||||
* Moving the current time to the end makes no sense then. */
|
||||
if (progress.progress_in_percent >= 100) return;
|
||||
|
||||
/* Set the 'last reported' props before manipulating the
|
||||
* player, so that the manipulation doesn't trigger more
|
||||
* API calls to remember what we just restored. */
|
||||
this.last_reported_progress_in_sec = progress.progress_in_sec;
|
||||
this.last_reported_progress_in_perc = progress.progress_in_perc;
|
||||
|
||||
console.log("Continuing playback at ", progress.progress_in_percent, "% from", progress.last_watched);
|
||||
this.player.currentTime(progress.progress_in_sec);
|
||||
this.player.play();
|
||||
return;
|
||||
default:
|
||||
console.log("Unknown code", xhr.status, "getting video progress information.");
|
||||
}
|
||||
};
|
||||
|
||||
$.get(this.fetch_progress_url)
|
||||
.fail(function(error) {
|
||||
console.log("Unable to fetch video progress information:", xhrErrorResponseMessage(error));
|
||||
})
|
||||
.done(on_done.bind(this));
|
||||
},
|
||||
|
||||
/* Pausing playback should report the progress.
|
||||
* This function is also called when playback stops at the end of the video,
|
||||
* so it's important to report in this case; otherwise progress will never
|
||||
* reach 100%. */
|
||||
on_pause: function(event) {
|
||||
this.inspect_progress(true);
|
||||
},
|
||||
|
||||
on_timeupdate: function() {
|
||||
this.inspect_progress(false);
|
||||
},
|
||||
|
||||
inspect_progress: function(force_report) {
|
||||
// Don't report seeking when paused, only report actual playback.
|
||||
if (!force_report && this.player.paused()) return;
|
||||
|
||||
let now_in_ms = new Date().getTime();
|
||||
if (!force_report && now_in_ms - this.last_wallclock_time_ms < PROGRESS_RELAXING_TIME_MSEC) {
|
||||
// We're trying too fast, don't bother doing any other calculation.
|
||||
// console.log('skipping, already reported', now_in_ms - this.last_wallclock_time_ms, 'ms ago.');
|
||||
return;
|
||||
}
|
||||
|
||||
let progress_in_sec = this.player.currentTime();
|
||||
let duration_in_sec = this.player.duration();
|
||||
|
||||
/* Instead of reporting the current time, report reaching the end
|
||||
* of the video. This ensures that it's properly marked as 'done'. */
|
||||
if (!this.reported_looping) {
|
||||
let margin = 1.25 * PROGRESS_RELAXING_TIME_MSEC / 1000.0;
|
||||
let is_looping = progress_in_sec == 0 && duration_in_sec - this.last_inspected_progress_in_sec < margin;
|
||||
this.last_inspected_progress_in_sec = progress_in_sec;
|
||||
if (is_looping) {
|
||||
this.reported_looping = true;
|
||||
this.report(this.player.duration(), 100, now_in_ms);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (Math.abs(progress_in_sec - this.last_reported_progress_in_sec) < 0.01) {
|
||||
// Already reported this, don't bother doing it again.
|
||||
return;
|
||||
}
|
||||
let progress_in_perc = 100 * progress_in_sec / duration_in_sec;
|
||||
let diff_sec = progress_in_sec - this.last_reported_progress_in_sec;
|
||||
let diff_perc = progress_in_perc - this.last_reported_progress_in_perc;
|
||||
|
||||
if (!force_report
|
||||
&& Math.abs(diff_perc) < PROGRESS_REPORT_INTERVAL_PERC
|
||||
&& Math.abs(diff_sec) < PROGRESS_REPORT_INTERVAL_SEC) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.report(progress_in_sec, progress_in_perc, now_in_ms);
|
||||
},
|
||||
|
||||
report: function(progress_in_sec, progress_in_perc, now_in_ms) {
|
||||
/* Store when we tried, not when we succeeded. This function can be
|
||||
* called every 15-250 milliseconds, so we don't want to retry with
|
||||
* that frequency. */
|
||||
this.last_wallclock_time_ms = now_in_ms;
|
||||
|
||||
let on_fail = function(error) {
|
||||
/* Don't show (as in: a toastr popup) the error to the user,
|
||||
* as it doesn't impact their ability to play the video.
|
||||
* Also show the error only once, instead of spamming. */
|
||||
if (this.reported_error) return;
|
||||
|
||||
let msg = xhrErrorResponseMessage(error);
|
||||
console.log('Unable to report viewing progress:', msg);
|
||||
this.reported_error = true;
|
||||
};
|
||||
let on_done = function() {
|
||||
this.last_reported_progress_in_sec = progress_in_sec;
|
||||
this.last_reported_progress_in_perc = progress_in_perc;
|
||||
};
|
||||
|
||||
$.post(this.report_url, {
|
||||
progress_in_sec: progress_in_sec,
|
||||
progress_in_perc: Math.round(progress_in_perc),
|
||||
})
|
||||
.fail(on_fail.bind(this))
|
||||
.done(on_done.bind(this));
|
||||
},
|
||||
});
|
||||
|
||||
var RememberVolumePlugin = videojs.extend(Plugin, {
|
||||
constructor: function(player, options) {
|
||||
Plugin.call(this, player, options);
|
||||
player.on('volumechange', this.on_volumechange.bind(this));
|
||||
this.restore_volume();
|
||||
},
|
||||
|
||||
restore_volume: function() {
|
||||
let volume_str = localStorage.getItem('video-player-volume');
|
||||
if (volume_str == null) return;
|
||||
this.player.volume(1.0 * volume_str);
|
||||
},
|
||||
|
||||
on_volumechange: function(event) {
|
||||
localStorage.setItem('video-player-volume', this.player.volume());
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
// Register our watch-progress-bookkeeping plugin.
|
||||
videojs.registerPlugin('progressPlugin', VideoProgressPlugin);
|
||||
videojs.registerPlugin('rememberVolumePlugin', RememberVolumePlugin);
|
@@ -143,12 +143,17 @@ nav.sidebar
|
||||
left: 0
|
||||
width: $sidebar-width
|
||||
height: 100%
|
||||
background-color: $color-background-nav
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
> ul > li > .navbar-item
|
||||
padding-top: 10px
|
||||
padding-bottom: 10px
|
||||
background: red
|
||||
|
||||
.dropdown
|
||||
min-width: $sidebar-width
|
||||
|
||||
.dropdown-menu
|
||||
top: initial
|
||||
bottom: 3px
|
||||
@@ -159,7 +164,7 @@ nav.sidebar
|
||||
li a
|
||||
justify-content: flex-start
|
||||
|
||||
ul
|
||||
> ul
|
||||
width: 100%
|
||||
margin: 0
|
||||
padding: 0
|
||||
@@ -172,25 +177,11 @@ nav.sidebar
|
||||
|
||||
a.navbar-item, button
|
||||
display: flex
|
||||
color: $color-text-light-hint
|
||||
font-size: 1.5em
|
||||
align-items: center
|
||||
justify-content: center
|
||||
padding: 10px 0
|
||||
background: transparent
|
||||
border: none
|
||||
width: 100%
|
||||
text-decoration: none
|
||||
|
||||
&:hover
|
||||
color: $color-text-light-primary
|
||||
&:active
|
||||
outline: none
|
||||
|
||||
&.cloud
|
||||
i
|
||||
position: relative
|
||||
left: -4px
|
||||
|
||||
a.dropdown-toggle
|
||||
padding: 0
|
||||
@@ -408,3 +399,68 @@ nav.sidebar
|
||||
top: -1px
|
||||
left: -19px
|
||||
z-index: 1
|
||||
|
||||
$loader-bar-width: 100px
|
||||
$loader-bar-height: 2px
|
||||
.loader-bar
|
||||
bottom: 0
|
||||
content: ''
|
||||
display: none
|
||||
height: 0
|
||||
overflow: hidden
|
||||
position: absolute
|
||||
visibility: hidden
|
||||
width: 100%
|
||||
z-index: 20
|
||||
|
||||
&:before
|
||||
animation: none
|
||||
background-color: $primary
|
||||
background-image: linear-gradient(to right, $primary-accent, $primary)
|
||||
content: ''
|
||||
display: block
|
||||
height: $loader-bar-height
|
||||
left: -$loader-bar-width
|
||||
position: absolute
|
||||
width: $loader-bar-width
|
||||
|
||||
&.active
|
||||
display: block
|
||||
height: $loader-bar-height
|
||||
visibility: visible
|
||||
|
||||
&:before
|
||||
animation: loader-bar-slide 2s linear infinite
|
||||
|
||||
@keyframes loader-bar-slide
|
||||
from
|
||||
left: -($loader-bar-width / 2)
|
||||
width: 3%
|
||||
|
||||
50%
|
||||
width: 20%
|
||||
|
||||
70%
|
||||
width: 70%
|
||||
|
||||
80%
|
||||
left: 50%
|
||||
|
||||
95%
|
||||
left: 120%
|
||||
|
||||
to
|
||||
left: 100%
|
||||
|
||||
.progress-bar
|
||||
background-color: $primary
|
||||
background-image: linear-gradient(to right, $primary-accent, $primary)
|
||||
|
||||
.node-details-description
|
||||
+node-details-description
|
||||
|
||||
@include media-breakpoint-up(lg)
|
||||
max-width: map-get($grid-breakpoints, "md")
|
||||
|
||||
@include media-breakpoint-up(xl)
|
||||
max-width: map-get($grid-breakpoints, "lg")
|
||||
|
@@ -1,7 +1,9 @@
|
||||
$comments-width-max: 710px
|
||||
|
||||
.comments-container
|
||||
max-width: $comments-width-max
|
||||
position: relative
|
||||
width: 100%
|
||||
|
||||
#comments-reload
|
||||
text-align: center
|
||||
@@ -314,9 +316,6 @@ $comments-width-max: 710px
|
||||
color: $color-success
|
||||
|
||||
.comment-reply
|
||||
&-container
|
||||
background-color: $color-background
|
||||
|
||||
/* Little gravatar icon on the left */
|
||||
&-avatar
|
||||
img
|
||||
@@ -333,7 +332,7 @@ $comments-width-max: 710px
|
||||
width: 100%
|
||||
|
||||
&-field
|
||||
background-color: $color-background-dark
|
||||
background-color: $color-background-light
|
||||
border-radius: 3px
|
||||
box-shadow: inset 0 0 2px 0 rgba(darken($color-background-dark, 20%), .5)
|
||||
display: flex
|
||||
@@ -342,6 +341,7 @@ $comments-width-max: 710px
|
||||
|
||||
textarea
|
||||
+node-details-description
|
||||
background-color: $color-background-light
|
||||
border-bottom-right-radius: 0
|
||||
border-top-right-radius: 0
|
||||
border: none
|
||||
@@ -376,7 +376,6 @@ $comments-width-max: 710px
|
||||
|
||||
&.filled
|
||||
textarea
|
||||
background-color: $color-background-light
|
||||
border-bottom: thin solid $color-background
|
||||
|
||||
&:focus
|
||||
|
@@ -12,9 +12,10 @@ $color-background-active-dark: hsl(hue($color-background-active), 50%, 50%) !def
|
||||
$font-body: 'Roboto' !default
|
||||
$font-headings: 'Lato' !default
|
||||
$font-size: 14px !default
|
||||
$font-size-xs: .75rem
|
||||
$font-size-xxs: .65rem
|
||||
|
||||
$color-text: #4d4e53 !default
|
||||
|
||||
$color-text-dark: $color-text !default
|
||||
$color-text-dark-primary: #646469 !default
|
||||
$color-text-dark-secondary: #9E9FA2 !default
|
||||
@@ -29,6 +30,7 @@ $color-primary: #009eff !default
|
||||
$color-primary-light: hsl(hue($color-primary), 30%, 90%) !default
|
||||
$color-primary-dark: hsl(hue($color-primary), 80%, 30%) !default
|
||||
$color-primary-accent: hsl(hue($color-primary), 100%, 50%) !default
|
||||
$primary-accent: #0bd
|
||||
|
||||
$color-secondary: #f42942 !default
|
||||
$color-secondary-light: hsl(hue($color-secondary), 30%, 90%) !default
|
||||
@@ -96,13 +98,16 @@ $screen-xs-max: $screen-sm-min - 1 !default
|
||||
$screen-sm-max: $screen-md-min - 1 !default
|
||||
$screen-md-max: $screen-lg-min - 1 !default
|
||||
|
||||
$sidebar-width: 50px !default
|
||||
$sidebar-width: 40px !default
|
||||
|
||||
/* Project specifics */
|
||||
$project_nav-width: 250px !default
|
||||
$project_nav-width: 275px !default
|
||||
$project_nav-width-lg: $project_nav-width * 1.2 !default
|
||||
$project_nav-width-md: $project_nav-width
|
||||
$project_nav-width-sm: $project_nav-width * 0.8 !default
|
||||
$project_nav-width-xs: 100% !default
|
||||
$project-sidebar-width: 40px !default
|
||||
$project_header-height: 37px !default
|
||||
$project_footer-height: 30px !default
|
||||
$project_header-height: 40px !default
|
||||
|
||||
$node-type-asset_image: #e87d86 !default
|
||||
$node-type-asset_file: #CC91C7 !default
|
||||
@@ -143,8 +148,16 @@ $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)
|
||||
|
@@ -24,13 +24,16 @@
|
||||
color: $color-secondary
|
||||
|
||||
#notifications-toggle
|
||||
color: $color-text
|
||||
cursor: pointer
|
||||
font-size: 1.5em
|
||||
position: relative
|
||||
user-select: none
|
||||
|
||||
> i:before
|
||||
content: '\e815'
|
||||
font-size: 1.3em
|
||||
position: relative
|
||||
top: 2px
|
||||
|
||||
&.has-notifications
|
||||
> i:before
|
||||
@@ -45,10 +48,10 @@
|
||||
border-color: transparent transparent $color-background transparent
|
||||
border-style: solid
|
||||
border-width: 0 8px 8px 8px
|
||||
bottom: -15px
|
||||
bottom: -10px
|
||||
height: 0
|
||||
position: absolute
|
||||
right: 22px
|
||||
right: 7px
|
||||
visibility: hidden
|
||||
width: 0
|
||||
|
||||
|
@@ -93,15 +93,6 @@ ul.sharing-users-list
|
||||
color: lighten($color-danger, 10%)
|
||||
|
||||
|
||||
.sharing-users-info
|
||||
padding-left: 15px
|
||||
border-left: thin solid $color-text-dark-hint
|
||||
|
||||
p
|
||||
font:
|
||||
size: 1.1em
|
||||
weight: 300
|
||||
|
||||
.sharing-users-search
|
||||
.disabled
|
||||
color: $color-text-dark-secondary
|
||||
@@ -158,24 +149,26 @@ ul.list-generic
|
||||
list-style: none
|
||||
|
||||
> li
|
||||
padding: 5px 0
|
||||
display: flex
|
||||
align-items: center
|
||||
border-top: thin solid $color-background
|
||||
display: flex
|
||||
padding: 5px 0
|
||||
|
||||
&:first-child
|
||||
border-top: none
|
||||
|
||||
&:hover .item a
|
||||
color: $color-primary
|
||||
color: $primary
|
||||
|
||||
a
|
||||
flex: 1
|
||||
|
||||
&.active
|
||||
color: $primary !important
|
||||
font-weight: bold
|
||||
|
||||
.actions
|
||||
margin-left: auto
|
||||
.btn
|
||||
font-size: .7em
|
||||
|
||||
span
|
||||
color: $color-text-dark-secondary
|
||||
|
@@ -6,24 +6,12 @@ body.workshops
|
||||
#project-container
|
||||
+container-behavior
|
||||
|
||||
body.blog
|
||||
background-color: white
|
||||
|
||||
#project_nav,
|
||||
#project_nav-container
|
||||
+media-md
|
||||
width: $project_nav-width * 1.4
|
||||
+media-sm
|
||||
width: $project_nav-width * 1.1
|
||||
+media-xs
|
||||
width: 100%
|
||||
width: $project_nav-width * 1.5
|
||||
|
||||
#project-container
|
||||
display: flex
|
||||
flex-direction: row
|
||||
min-height: 300px
|
||||
position: relative
|
||||
overflow-y: auto
|
||||
z-index: $z-index-base
|
||||
|
||||
+media-xs
|
||||
@@ -36,14 +24,19 @@ body.blog
|
||||
+media-xs
|
||||
flex-direction: column-reverse
|
||||
|
||||
#project-side-container+#project_context-header
|
||||
width: 100%
|
||||
left: 0
|
||||
|
||||
#project_nav,
|
||||
#project_nav-container
|
||||
+media-lg
|
||||
width: $project_nav-width * 1.33
|
||||
width: $project_nav-width-lg
|
||||
+media-sm
|
||||
width: $project_nav-width-sm
|
||||
+media-xs
|
||||
width: $project_nav-width-xs
|
||||
|
||||
width: $project_nav-width
|
||||
|
||||
#project_nav-container
|
||||
+media-xs
|
||||
display: block
|
||||
width: 100%
|
||||
@@ -52,8 +45,6 @@ body.blog
|
||||
|
||||
position: fixed
|
||||
z-index: $z-index-base + 5
|
||||
width: $project_nav-width
|
||||
|
||||
|
||||
#project_sidebar
|
||||
width: $project-sidebar-width
|
||||
@@ -80,24 +71,14 @@ body.blog
|
||||
height: $project-sidebar-width
|
||||
position: relative
|
||||
|
||||
+media-xs
|
||||
border-bottom: 2px solid transparent
|
||||
+media-sm
|
||||
border-bottom: 2px solid transparent
|
||||
+media-md
|
||||
border-left: 2px solid transparent
|
||||
+media-lg
|
||||
border-left: 2px solid transparent
|
||||
|
||||
&:first-child
|
||||
border-top: thin solid transparent
|
||||
|
||||
&:hover,
|
||||
&.active
|
||||
cursor: pointer
|
||||
|
||||
a
|
||||
color: $primary
|
||||
i
|
||||
+active-gradient
|
||||
|
||||
a
|
||||
align-items: center
|
||||
@@ -112,13 +93,6 @@ body.blog
|
||||
height: $project-sidebar-width
|
||||
width: $project-sidebar-width
|
||||
|
||||
#project-loading
|
||||
align-items: center
|
||||
display: flex
|
||||
height: $project-sidebar-width
|
||||
justify-content: center
|
||||
width: $project-sidebar-width
|
||||
|
||||
|
||||
#search-container #project_sidebar ul.project-tabs li.tabs-thumbnail
|
||||
background-color: $color-background-nav-dark
|
||||
@@ -133,10 +107,11 @@ body.blog
|
||||
/* Container for navigation on the left */
|
||||
#project_nav
|
||||
+media-lg
|
||||
width: $project_nav-width * 1.33
|
||||
|
||||
width: $project_nav-width-lg
|
||||
+media-sm
|
||||
width: $project_nav-width-sm
|
||||
+media-xs
|
||||
width: initial
|
||||
width: $project_nav-width-xs
|
||||
|
||||
display: block
|
||||
left: 0
|
||||
@@ -150,86 +125,10 @@ body.blog
|
||||
|
||||
|
||||
/* Header with name and node edit tools */
|
||||
#project_nav-header,
|
||||
#project_context-header
|
||||
align-items: center
|
||||
color: white
|
||||
display: flex
|
||||
position: relative
|
||||
|
||||
#project_context-header
|
||||
align-items: center
|
||||
color: $color-text-dark-secondary
|
||||
display: flex
|
||||
height: $project_header-height
|
||||
min-height: $project_header-height
|
||||
position: fixed
|
||||
top: $project_header-height + 1
|
||||
transition: box-shadow 250ms ease-in-out
|
||||
right: 0
|
||||
z-index: $z-index-base + 3
|
||||
|
||||
&.is-offset
|
||||
box-shadow: 0 0 25px rgba(black, .2)
|
||||
|
||||
span#status-bar
|
||||
text-align: left
|
||||
|
||||
#project_nav-header
|
||||
left: 0
|
||||
position: absolute
|
||||
top: 0
|
||||
width: 100%
|
||||
z-index: $z-index-base + 3
|
||||
|
||||
/* Name of the project */
|
||||
.project-title
|
||||
+text-overflow-ellipsis
|
||||
height: 100%
|
||||
max-width: 100%
|
||||
width: 100%
|
||||
|
||||
span#status-bar
|
||||
position: absolute
|
||||
left: 0
|
||||
top: 10px
|
||||
min-width: 33%
|
||||
padding: 5px 15px
|
||||
color: $color-text-dark
|
||||
opacity: 0
|
||||
z-index: 1
|
||||
font-weight: 400
|
||||
white-space: nowrap
|
||||
transition: all 250ms ease-in-out
|
||||
|
||||
+media-xs
|
||||
left: 0
|
||||
min-width: 100%
|
||||
|
||||
i
|
||||
margin-right: 5px
|
||||
|
||||
&.info
|
||||
color: $color-info
|
||||
&.error
|
||||
color: $danger
|
||||
&.warning
|
||||
color: $color-warning
|
||||
&.success
|
||||
color: $color-success
|
||||
&.default
|
||||
color: $color-text-light
|
||||
|
||||
&.active
|
||||
opacity: 1
|
||||
|
||||
span#project-edit-title
|
||||
position: absolute
|
||||
padding: 15px 20px
|
||||
font:
|
||||
size: 1.1em
|
||||
weight: 400
|
||||
white-space: nowrap
|
||||
|
||||
|
||||
/* Edit Asset buttons */
|
||||
.project-mode-view,
|
||||
@@ -238,24 +137,16 @@ span#project-edit-title
|
||||
display: none
|
||||
|
||||
ul.project-edit-tools
|
||||
align-items: center
|
||||
display: flex
|
||||
list-style-type: none
|
||||
margin: 0 0 0 auto
|
||||
padding: 0
|
||||
li:not(.disabled)
|
||||
.btn
|
||||
background-color: $white
|
||||
|
||||
+media-xs
|
||||
width: 100%
|
||||
margin: 0 auto
|
||||
justify-content: space-around
|
||||
&:hover
|
||||
border-color: $primary
|
||||
background-color: $white
|
||||
color: $primary
|
||||
|
||||
li
|
||||
a, button
|
||||
padding: $dropdown-item-padding-y ($dropdown-item-padding-x / 2)
|
||||
|
||||
i
|
||||
padding-right: 10px
|
||||
|
||||
&.button-save
|
||||
&.field-error
|
||||
a
|
||||
@@ -283,10 +174,6 @@ ul.project-edit-tools
|
||||
cursor: default
|
||||
+pulse-75
|
||||
|
||||
&.button-add,
|
||||
&.button-edit
|
||||
min-width: 80px
|
||||
|
||||
&.featured
|
||||
a
|
||||
color: $color-warning
|
||||
@@ -303,8 +190,6 @@ ul.project-edit-tools
|
||||
|
||||
&.dropdown
|
||||
li
|
||||
padding: 0
|
||||
|
||||
a
|
||||
color: $body-color
|
||||
display: block
|
||||
@@ -336,81 +221,6 @@ ul.project-edit-tools
|
||||
/* // Extra asset tools in dropdown */
|
||||
/* // Edit Asset buttons */
|
||||
|
||||
#project-loading
|
||||
color: $color-text-light-primary
|
||||
margin-left: auto
|
||||
display: inline-block
|
||||
background-color: transparent
|
||||
opacity: 0
|
||||
transition: opacity 150ms ease-in-out
|
||||
+position-center-translate
|
||||
|
||||
i
|
||||
position: relative
|
||||
top: 2px
|
||||
|
||||
&.active
|
||||
background-color: rgba($color-background-nav-dark, .7)
|
||||
opacity: 1
|
||||
|
||||
i
|
||||
+spin
|
||||
animation-duration: .5s
|
||||
|
||||
#project-header+#project_tree,
|
||||
#project-header+#project_context-container
|
||||
margin-top: $project_header-height
|
||||
|
||||
|
||||
ul.project_nav-edit-list
|
||||
list-style: none
|
||||
padding: 0
|
||||
margin: $project_header-height 0 0 0
|
||||
|
||||
li
|
||||
background-color: $color-background
|
||||
border-bottom: 1px solid $color-background-dark
|
||||
color: $color-text-dark
|
||||
position: relative
|
||||
|
||||
&:hover
|
||||
cursor: pointer
|
||||
background-color: $color-background-light
|
||||
a
|
||||
padding: 10px 15px
|
||||
display: inline-block
|
||||
width: 100%
|
||||
text-decoration: none
|
||||
color: $color-text-dark
|
||||
|
||||
i
|
||||
padding-right: 15px
|
||||
|
||||
&.active
|
||||
background-color: white
|
||||
|
||||
a
|
||||
color: $color-primary-dark
|
||||
|
||||
.project_nav-toggle-btn
|
||||
position: absolute
|
||||
bottom: 0
|
||||
width: 100%
|
||||
padding: 10px
|
||||
text-align: center
|
||||
color: $color-text-light-hint
|
||||
cursor: pointer
|
||||
|
||||
&:hover
|
||||
color: $color-text-light
|
||||
|
||||
i
|
||||
font-size: 1.3em
|
||||
|
||||
+media-xs
|
||||
visibility: hidden
|
||||
display: none
|
||||
|
||||
#project_context
|
||||
position: relative
|
||||
display: flex
|
||||
@@ -419,13 +229,7 @@ ul.project_nav-edit-list
|
||||
height: 100%
|
||||
background-color: white
|
||||
|
||||
#project_context-header+#project_context
|
||||
padding-top: $project_header-height
|
||||
|
||||
#node-container
|
||||
background-color: white
|
||||
flex: 1
|
||||
|
||||
/* For error messages (403) and other overlaid text*/
|
||||
#node-overlay
|
||||
z-index: $z-index-base + 2
|
||||
@@ -456,16 +260,13 @@ ul.project_nav-edit-list
|
||||
#project_tree
|
||||
+media-xs
|
||||
margin-top: 0
|
||||
|
||||
// margin-top: $project_header-height //so it's right below the project title.
|
||||
overflow-y: auto // show vertical scrollbars when needed.
|
||||
padding: 0 0 5px 0 // some padding on top/bottom of jstree.
|
||||
padding: 5px 0 // some padding on top/bottom of jsTree.
|
||||
position: relative
|
||||
|
||||
&.edit
|
||||
margin-top: 0
|
||||
|
||||
|
||||
/* Node Context */
|
||||
=project-node-title
|
||||
font-size: 1.5em
|
||||
@@ -701,12 +502,11 @@ section.node-preview
|
||||
align-items: center
|
||||
background-color: black
|
||||
color: $color-text-light-primary
|
||||
display: flex
|
||||
flex: 1
|
||||
// display: flex
|
||||
justify-content: center
|
||||
max-height: 500px
|
||||
min-height: 200px
|
||||
overflow: hidden
|
||||
// min-height: 200px
|
||||
// overflow: hidden
|
||||
|
||||
iframe
|
||||
width: 100%
|
||||
@@ -727,6 +527,8 @@ section.node-preview
|
||||
|
||||
&.image
|
||||
cursor: zoom-in
|
||||
display: flex
|
||||
overflow: hidden
|
||||
|
||||
&.video
|
||||
background-color: black
|
||||
@@ -781,12 +583,6 @@ section.node-preview
|
||||
color: $color-warning
|
||||
margin-right: 10px
|
||||
|
||||
&.group
|
||||
align-items: center
|
||||
display: flex
|
||||
padding: 20px
|
||||
position: relative
|
||||
|
||||
&.project
|
||||
background-color: black
|
||||
width: 100%
|
||||
@@ -831,11 +627,6 @@ section.node-preview-forbidden
|
||||
hr
|
||||
opacity: .5
|
||||
|
||||
section.node-details-container
|
||||
background-color: white
|
||||
|
||||
&.project
|
||||
padding-bottom: 15px
|
||||
|
||||
/* Narrower details for about page (since it doesn't have navtree) */
|
||||
body.about
|
||||
@@ -847,8 +638,7 @@ body.about
|
||||
.node-title
|
||||
+project-node-title
|
||||
|
||||
section.node-details-container,
|
||||
section.node-preview.group
|
||||
section.node-details-container
|
||||
position: relative
|
||||
|
||||
.node-details-header
|
||||
@@ -861,46 +651,17 @@ section.node-preview.group
|
||||
width: 100%
|
||||
max-width: 100%
|
||||
|
||||
.node-details-meta-actions
|
||||
margin-left: auto
|
||||
|
||||
.btn-browsetoggle
|
||||
+button(lighten($color-background-nav, 20%), 3px)
|
||||
width: 48px
|
||||
text-align: center
|
||||
padding: 2px 4px
|
||||
i
|
||||
font-size: 1.3em
|
||||
padding: 0
|
||||
margin: 0
|
||||
|
||||
|
||||
.node-details-description
|
||||
+node-details-description
|
||||
padding-left: 20px
|
||||
padding-right: 20px
|
||||
|
||||
.node-details-meta
|
||||
background-color: $color-background-light
|
||||
border-bottom: thin solid $color-background
|
||||
display: flex
|
||||
flex-direction: column
|
||||
font-weight: lighter
|
||||
padding: 10px 20px
|
||||
|
||||
> ul
|
||||
align-items: center
|
||||
display: flex
|
||||
list-style-type: none
|
||||
margin: 0
|
||||
padding: 0
|
||||
|
||||
> li
|
||||
align-items: baseline
|
||||
color: $color-text-dark-secondary
|
||||
display: flex
|
||||
padding-left: 10px
|
||||
margin-left: 10px
|
||||
padding-left: 5px
|
||||
margin-left: 5px
|
||||
|
||||
&:first-child
|
||||
margin-left: 0
|
||||
@@ -913,55 +674,31 @@ section.node-preview.group
|
||||
color: $color-success
|
||||
|
||||
&.download
|
||||
position: relative
|
||||
|
||||
button
|
||||
+button($color-success, 3px)
|
||||
|
||||
.dropdown-toggle
|
||||
padding-right: 0
|
||||
|
||||
i.icon-dropdown-menu
|
||||
padding-left: 10px
|
||||
|
||||
/* Download dropdown options */
|
||||
ul.dropdown-menu
|
||||
width: auto
|
||||
min-width: 240px
|
||||
padding: 0
|
||||
margin: 0
|
||||
top: initial
|
||||
left: initial
|
||||
right: 0
|
||||
bottom: 35px
|
||||
border: thin solid rgba(darken($color-background, 5%), .3)
|
||||
border-top-left-radius: 3px
|
||||
border-top-right-radius: 3px
|
||||
border-bottom-left-radius: 0
|
||||
border-bottom-right-radius: 0
|
||||
|
||||
li
|
||||
padding: 0
|
||||
text-align: right
|
||||
clear: both
|
||||
display: flex
|
||||
align-items: center
|
||||
display: block
|
||||
|
||||
a
|
||||
display: flex
|
||||
padding: 10px 15px
|
||||
width: 100%
|
||||
|
||||
&:hover
|
||||
color: $color-primary
|
||||
background-color: transparent
|
||||
text-decoration: none
|
||||
|
||||
span
|
||||
color: $primary
|
||||
|
||||
&.length
|
||||
color: lighten($color-primary, 10%)
|
||||
|
||||
span
|
||||
color: $body-color
|
||||
|
||||
&.length
|
||||
float: left
|
||||
margin-right: auto
|
||||
color: $color-text-dark-hint
|
||||
padding-right: 15px
|
||||
&.format
|
||||
@@ -998,34 +735,38 @@ section.node-preview.group
|
||||
&.status
|
||||
color: $color-text-dark
|
||||
|
||||
ul.blender-id-badges
|
||||
padding-left: 0
|
||||
list-style-type: none
|
||||
display: flex
|
||||
|
||||
> li
|
||||
margin: 0
|
||||
padding: 0
|
||||
|
||||
> a
|
||||
display: block
|
||||
padding: 2px
|
||||
|
||||
img
|
||||
// TODO(Pablo): fix this so that it keeps the aspect ratio okay when the original image isn't square.
|
||||
max-width: 16px
|
||||
max-height: 16px
|
||||
|
||||
.node-details-license
|
||||
align-items: center
|
||||
border-bottom: thin solid $color-background
|
||||
color: $color-text-dark
|
||||
display: flex
|
||||
font-weight: lighter
|
||||
padding: 10px 20px
|
||||
|
||||
color: $color-text-dark-secondary
|
||||
&:hover
|
||||
color: $color-text-dark
|
||||
color: $primary
|
||||
|
||||
span.type
|
||||
i
|
||||
color: $color-text-dark-primary
|
||||
font-size: 1.8rem
|
||||
margin-left: -5px
|
||||
padding-right: 10px
|
||||
i
|
||||
font-size: 1.2rem
|
||||
margin-left: -15px
|
||||
|
||||
&:after
|
||||
top: 2px
|
||||
left: -22px
|
||||
position: relative
|
||||
&:before
|
||||
top: 2px
|
||||
position: relative
|
||||
&:after
|
||||
left: -22px
|
||||
position: relative
|
||||
top: 1px
|
||||
|
||||
span
|
||||
text-transform: uppercase
|
||||
|
||||
section.node-details-container
|
||||
&.storage
|
||||
@@ -1196,9 +937,6 @@ a.learn-more
|
||||
font-size: 1.6em
|
||||
left: 7px
|
||||
|
||||
.ribbon
|
||||
+ribbon
|
||||
|
||||
.info
|
||||
width: 100%
|
||||
height: 100%
|
||||
@@ -1233,12 +971,6 @@ a.learn-more
|
||||
text-transform: capitalize
|
||||
|
||||
|
||||
section.node-children
|
||||
&.group, &.storage
|
||||
flex: 1
|
||||
padding: 10px 0 25px 20px
|
||||
+clearfix
|
||||
|
||||
.list-node-children-container
|
||||
position: relative
|
||||
width: $list-node-children-item-width
|
||||
@@ -1825,9 +1557,6 @@ section.node-children
|
||||
padding: 20px
|
||||
|
||||
.form-group
|
||||
position: relative
|
||||
margin: 0 auto 30px auto
|
||||
|
||||
&.tags .select2-container
|
||||
.select2-selection
|
||||
+input-generic
|
||||
@@ -2046,7 +1775,7 @@ section.node-children
|
||||
box-shadow: 0 5px 35px rgba(black, .2)
|
||||
color: $color-text-dark-primary
|
||||
position: absolute
|
||||
top: 0
|
||||
top: -$project_header-height
|
||||
left: 0
|
||||
right: 0
|
||||
width: 80%
|
||||
@@ -2074,7 +1803,7 @@ section.node-children
|
||||
&.visible
|
||||
visibility: visible
|
||||
opacity: 1
|
||||
top: $project_header-height
|
||||
top: 0
|
||||
|
||||
.overlay-container
|
||||
.title
|
||||
@@ -2291,3 +2020,6 @@ section.node-children
|
||||
margin: 25px 0 0 0
|
||||
padding: 5px 35px
|
||||
text-align: center
|
||||
|
||||
.ribbon
|
||||
+ribbon
|
||||
|
@@ -16,7 +16,7 @@ $search-hit-width_grid: 100px
|
||||
.search-hit-name
|
||||
font-weight: 400
|
||||
padding-top: 8px
|
||||
color: $color-primary-dark
|
||||
color: $primary
|
||||
|
||||
.search-hit
|
||||
padding: 0
|
||||
@@ -35,7 +35,7 @@ $search-hit-width_grid: 100px
|
||||
+clearfix
|
||||
|
||||
& em
|
||||
color: $color-primary-dark
|
||||
color: $primary
|
||||
font-style: normal
|
||||
|
||||
&:hover
|
||||
@@ -70,7 +70,7 @@ $search-hit-width_grid: 100px
|
||||
min-width: 350px
|
||||
border-bottom-left-radius: 3px
|
||||
border-bottom-right-radius: 3px
|
||||
border-top: 3px solid lighten($color-primary, 5%)
|
||||
border-top: 3px solid lighten($primary, 5%)
|
||||
overflow: hidden
|
||||
|
||||
.tt-suggestion
|
||||
@@ -92,235 +92,51 @@ $search-hit-width_grid: 100px
|
||||
&.tt-cursor:hover .search-hit
|
||||
background-color: lighten($color-background, 5%)
|
||||
|
||||
#search-container
|
||||
display: flex
|
||||
min-height: 600px
|
||||
background-color: white
|
||||
.search-list
|
||||
width: 30%
|
||||
|
||||
+media-lg
|
||||
padding-left: 0
|
||||
padding-right: 0
|
||||
.card-deck.card-deck-vertical
|
||||
.card .embed-responsive
|
||||
max-width: 80px
|
||||
|
||||
#search-sidebar
|
||||
width: 20%
|
||||
background-color: $color-background-light
|
||||
input.search-field
|
||||
border: none
|
||||
border-bottom: 2px solid rgba($primary, .2)
|
||||
border-radius: 0
|
||||
width: 100%
|
||||
transition: border 100ms ease-in-out
|
||||
|
||||
+media-lg
|
||||
border-top-left-radius: 3px
|
||||
&::placeholder
|
||||
color: $color-text-dark-secondary
|
||||
&:placeholder-shown
|
||||
border-bottom-color: $primary
|
||||
|
||||
input.search-field
|
||||
background-color: $color-background-nav-dark
|
||||
font-size: 1.1em
|
||||
color: white
|
||||
margin-bottom: 10px
|
||||
&:focus
|
||||
outline: none
|
||||
border: none
|
||||
border-bottom: 2px solid rgba($color-primary, .2)
|
||||
border-radius: 0
|
||||
width: 100%
|
||||
padding: 5px 15px
|
||||
height: 50px
|
||||
transition: border 100ms ease-in-out
|
||||
border-bottom: 2px solid lighten($primary, 5%)
|
||||
|
||||
&::placeholder
|
||||
color: $color-text-dark-secondary
|
||||
&:placeholder-shown
|
||||
border-bottom-color: $color-primary
|
||||
.search-details
|
||||
width: 70%
|
||||
|
||||
&:focus
|
||||
outline: none
|
||||
border: none
|
||||
border-bottom: 2px solid lighten($color-primary, 5%)
|
||||
|
||||
.search-list-filters
|
||||
padding:
|
||||
left: 10px
|
||||
right: 10px
|
||||
|
||||
.panel.panel-default
|
||||
margin-bottom: 10px
|
||||
border-radius: 3px
|
||||
border: none
|
||||
background-color: white
|
||||
box-shadow: 1px 1px 0 rgba(black, .1)
|
||||
|
||||
a
|
||||
text-decoration: none
|
||||
|
||||
.toggleRefine
|
||||
display: block
|
||||
padding-left: 7px
|
||||
color: $color-text-dark
|
||||
text-transform: capitalize
|
||||
|
||||
&:hover
|
||||
text-decoration: none
|
||||
color: $color-primary
|
||||
|
||||
&.refined
|
||||
color: $color-primary
|
||||
|
||||
&:hover
|
||||
color: $color-danger
|
||||
|
||||
span
|
||||
&:before
|
||||
/* x icon */
|
||||
content: '\e84b'
|
||||
font-family: 'pillar-font'
|
||||
span
|
||||
&:before
|
||||
/* circle with dot */
|
||||
content: '\e82f'
|
||||
font-family: 'pillar-font'
|
||||
position: relative
|
||||
left: -7px
|
||||
font-size: .9em
|
||||
|
||||
span
|
||||
&:before
|
||||
/* empty circle */
|
||||
content: '\e82c'
|
||||
font-family: 'pillar-font'
|
||||
position: relative
|
||||
left: -7px
|
||||
font-size: .9em
|
||||
.facet_count
|
||||
color: $color-text-dark-secondary
|
||||
|
||||
|
||||
.panel-title, .panel-heading
|
||||
color: $color-text-dark-secondary
|
||||
font:
|
||||
size: 1em
|
||||
weight: 500
|
||||
|
||||
.panel-body
|
||||
padding-top: 0
|
||||
|
||||
.panel-title
|
||||
position: relative
|
||||
&:after
|
||||
content: '\e83b'
|
||||
font-family: 'pillar-font'
|
||||
position: absolute
|
||||
right: 0
|
||||
color: $color-text-dark-primary
|
||||
|
||||
.collapsed
|
||||
.panel-title:after
|
||||
content: '\e838'
|
||||
|
||||
|
||||
.search-list-stats
|
||||
color: $color-text-dark-hint
|
||||
padding: 10px 15px 0 15px
|
||||
text-align: center
|
||||
font-size: .9em
|
||||
+clearfix
|
||||
|
||||
#pagination
|
||||
ul.search-pagination
|
||||
text-align: center
|
||||
list-style-type: none
|
||||
margin: 0
|
||||
padding: 0
|
||||
width: 100%
|
||||
display: flex
|
||||
+clearfix
|
||||
|
||||
li
|
||||
display: inline-block
|
||||
margin: 5px auto
|
||||
|
||||
&:last-child
|
||||
border-color: transparent
|
||||
|
||||
a
|
||||
font-weight: 500
|
||||
padding: 5px 4px
|
||||
color: $color-text-dark-secondary
|
||||
|
||||
&:hover
|
||||
color: $color-text-dark-primary
|
||||
|
||||
&.disabled
|
||||
opacity: .6
|
||||
|
||||
&.active a
|
||||
color: $color-text-dark-primary
|
||||
font-weight: bold
|
||||
|
||||
#search-list
|
||||
width: 40%
|
||||
height: 100%
|
||||
padding: 0
|
||||
position: relative
|
||||
overflow-x: hidden
|
||||
#search-details
|
||||
position: relative
|
||||
#search-hit-container
|
||||
position: absolute // for scrollbars
|
||||
overflow-y: auto
|
||||
|
||||
#hits
|
||||
#error_container
|
||||
position: relative
|
||||
width: 100%
|
||||
background: white
|
||||
padding: 20px
|
||||
|
||||
#no-hits
|
||||
padding: 10px 15px
|
||||
color: $color-text-dark-secondary
|
||||
|
||||
.search-hit
|
||||
#search-loading
|
||||
visibility: hidden
|
||||
background-color: transparent
|
||||
font:
|
||||
size: 1.5em
|
||||
weight: 600
|
||||
position: absolute
|
||||
top: 0
|
||||
left: 0
|
||||
right: 0
|
||||
bottom: 0
|
||||
z-index: $z-index-base + 5
|
||||
opacity: 0
|
||||
cursor: default
|
||||
transition: opacity 50ms ease-in-out
|
||||
&.active
|
||||
visibility: visible
|
||||
opacity: 1
|
||||
|
||||
.spinner
|
||||
color: $color-background-nav
|
||||
background-color: white
|
||||
padding: 0
|
||||
width: 20px
|
||||
height: 20px
|
||||
border-radius: 50%
|
||||
position: absolute
|
||||
top: 7px
|
||||
right: 10px
|
||||
span
|
||||
padding: 5px
|
||||
+pulse
|
||||
|
||||
#search-details
|
||||
position: relative
|
||||
width: 40%
|
||||
border-left: 2px solid darken(white, 3%)
|
||||
|
||||
#search-hit-container
|
||||
position: absolute // for scrollbars
|
||||
width: 100%
|
||||
overflow-y: auto
|
||||
|
||||
#error_container
|
||||
position: relative
|
||||
background: white
|
||||
padding: 20px
|
||||
|
||||
#search-error
|
||||
display: none
|
||||
margin: 20px auto
|
||||
color: $color-danger
|
||||
text-align: center
|
||||
#search-error
|
||||
display: none
|
||||
margin: 20px auto
|
||||
color: $color-danger
|
||||
text-align: center
|
||||
|
||||
#search-container
|
||||
#node-container
|
||||
width: 100%
|
||||
max-width: 100%
|
||||
@@ -473,215 +289,118 @@ $search-hit-width_grid: 100px
|
||||
button
|
||||
width: 100%
|
||||
|
||||
.search-hit
|
||||
float: left
|
||||
box-shadow: none
|
||||
border: thin solid transparent
|
||||
border-top-color: darken(white, 8%)
|
||||
border-left: 3px solid transparent
|
||||
#project_sidebar+#search-sidebar,
|
||||
#project_sidebar+#search-sidebar+#search-container
|
||||
padding-left: $sidebar-width
|
||||
|
||||
color: $color-background-nav
|
||||
.search-project
|
||||
li.project
|
||||
display: none
|
||||
|
||||
width: 100%
|
||||
position: relative
|
||||
margin: 0
|
||||
padding: 7px 10px 7px 10px
|
||||
+clearfix
|
||||
|
||||
&:first-child
|
||||
border: thin solid transparent
|
||||
border-left: 3px solid transparent
|
||||
|
||||
&:hover
|
||||
opacity: 1
|
||||
text-decoration: none
|
||||
cursor: default
|
||||
color: darken($color-primary, 20%)
|
||||
background-color: $color-background-light
|
||||
|
||||
& .search-hit-name i
|
||||
color: darken($color-primary, 20%)
|
||||
|
||||
& .search-hit-thumbnail
|
||||
& .search-hit-thumbnail-icon
|
||||
transform: translate(-50%, -50%) scale(1.1)
|
||||
|
||||
.search-hit-name
|
||||
text-decoration: none
|
||||
&:hover
|
||||
color: darken($color-primary, 10%)
|
||||
|
||||
.search-hit-thumbnail
|
||||
cursor: pointer
|
||||
|
||||
.search-hit-thumbnail-icon
|
||||
transform: translate(-50%, -50%) scale(1)
|
||||
|
||||
&:active
|
||||
background-color: rgba($color-background, .5)
|
||||
opacity: .8
|
||||
color: $color-primary
|
||||
& .search-hit-name i
|
||||
color: $color-primary
|
||||
|
||||
&:focus
|
||||
border-color: rgba($color-primary, .2)
|
||||
|
||||
/* Class that gets added when we click on the item */
|
||||
&.active
|
||||
background-color: lighten($color-background, 2%)
|
||||
border-left: 3px solid $color-primary
|
||||
|
||||
.search-hit-name
|
||||
color: darken($color-primary, 10%)
|
||||
|
||||
.search-hit-meta
|
||||
span.when
|
||||
display: none
|
||||
span.context
|
||||
display: inline-block
|
||||
|
||||
.search-hit-thumbnail
|
||||
position: relative
|
||||
float: left
|
||||
min-width: $search-hit-width_list * 1.49
|
||||
max-width: $search-hit-width_list * 1.49
|
||||
height: $search-hit-width_list
|
||||
#search-sidebar
|
||||
.card
|
||||
margin-bottom: 10px
|
||||
border-radius: 3px
|
||||
background: $color-background
|
||||
margin-right: 12px
|
||||
text-align: center
|
||||
overflow: hidden
|
||||
+media-xs
|
||||
display: none
|
||||
+media-sm
|
||||
min-width: $search-hit-width_list
|
||||
max-width: $search-hit-width_list
|
||||
border: none
|
||||
background-color: white
|
||||
box-shadow: 1px 1px 0 rgba(black, .1)
|
||||
|
||||
img
|
||||
height: $search-hit-width_list
|
||||
width: auto
|
||||
a
|
||||
text-decoration: none
|
||||
|
||||
|
||||
.pi-video:before, .pi-file:before,
|
||||
.pi-group:before
|
||||
font-family: 'pillar-font'
|
||||
.pi-video:before
|
||||
content: '\e81d'
|
||||
.pi-file:before
|
||||
content: '\e825'
|
||||
.pi-group:before
|
||||
content: '\e80d'
|
||||
|
||||
.search-hit-thumbnail-icon
|
||||
position: absolute
|
||||
top: 50%
|
||||
left: 50%
|
||||
transform: translate(-50%, -50%)
|
||||
color: white
|
||||
font-size: 1.2em
|
||||
transition: none
|
||||
color: $color-text-dark-secondary
|
||||
|
||||
.dark
|
||||
text-shadow: none
|
||||
font-size: 1.3em
|
||||
|
||||
.search-hit-name
|
||||
position: relative
|
||||
font-size: 1.1em
|
||||
color: $color-text-dark-primary
|
||||
background-color: initial
|
||||
width: initial
|
||||
max-width: initial
|
||||
+text-overflow-ellipsis
|
||||
padding-top: 5px
|
||||
.toggleRefine
|
||||
display: block
|
||||
padding-left: 7px
|
||||
color: $color-text-dark
|
||||
text-transform: capitalize
|
||||
|
||||
&:hover
|
||||
cursor: pointer
|
||||
text-decoration: underline
|
||||
text-decoration: none
|
||||
color: $primary
|
||||
|
||||
em
|
||||
color: darken($color-primary, 15%)
|
||||
font-style: normal
|
||||
&.refined
|
||||
color: $primary
|
||||
|
||||
.search-hit-ribbon
|
||||
+ribbon
|
||||
right: -30px
|
||||
top: 5px
|
||||
&:hover
|
||||
color: $color-danger
|
||||
|
||||
span
|
||||
&:before
|
||||
/* x icon */
|
||||
content: '\e84b'
|
||||
font-family: 'pillar-font'
|
||||
span
|
||||
&:before
|
||||
/* circle with dot */
|
||||
content: '\e82f'
|
||||
font-family: 'pillar-font'
|
||||
position: relative
|
||||
left: -7px
|
||||
font-size: .9em
|
||||
|
||||
span
|
||||
font-size: 60%
|
||||
margin: 1px 0
|
||||
padding: 2px 35px
|
||||
|
||||
.search-hit-meta
|
||||
position: relative
|
||||
font-size: .9em
|
||||
&:before
|
||||
/* empty circle */
|
||||
content: '\e82c'
|
||||
font-family: 'pillar-font'
|
||||
position: relative
|
||||
left: -7px
|
||||
font-size: .9em
|
||||
.facet_count
|
||||
color: $color-text-dark-secondary
|
||||
background-color: initial
|
||||
padding: 3px 0 0 0
|
||||
text-decoration: none
|
||||
+text-overflow-ellipsis
|
||||
|
||||
span
|
||||
&.project
|
||||
color: $color-text-dark-secondary
|
||||
margin-right: 3px
|
||||
&.updated
|
||||
color: $color-text-dark-hint
|
||||
&.status
|
||||
font-size: .8em
|
||||
color: $color-text-dark-secondary
|
||||
border: thin solid $color-text-dark-hint
|
||||
padding: 3px 8px
|
||||
text-transform: uppercase
|
||||
border-radius: 3px
|
||||
margin-right: 5px
|
||||
&.media, &.node_type
|
||||
color: $color-text-dark-secondary
|
||||
text-transform: capitalize
|
||||
margin: 0 3px
|
||||
.card-title
|
||||
position: relative
|
||||
&:after
|
||||
content: '\e83b'
|
||||
font-family: 'pillar-font'
|
||||
position: absolute
|
||||
right: 0
|
||||
color: $color-text-dark-primary
|
||||
|
||||
&.when
|
||||
margin: 0 3px
|
||||
float: right
|
||||
display: block
|
||||
+media-lg
|
||||
display: block
|
||||
+media-md
|
||||
display: block
|
||||
+media-sm
|
||||
display: none
|
||||
+media-xs
|
||||
display: none
|
||||
.collapsed
|
||||
.card-title:after
|
||||
content: '\e838'
|
||||
|
||||
&.context
|
||||
margin: 0
|
||||
float: right
|
||||
display: none
|
||||
|
||||
&:hover
|
||||
cursor: pointer
|
||||
.search-hit-name-user
|
||||
color: $color-primary
|
||||
.search-list-stats
|
||||
color: $color-text-dark-hint
|
||||
padding: 10px 15px 0 15px
|
||||
text-align: center
|
||||
font-size: .9em
|
||||
+clearfix
|
||||
|
||||
&.users
|
||||
em
|
||||
font-style: normal
|
||||
color: $color-primary
|
||||
.search-pagination
|
||||
text-align: center
|
||||
list-style-type: none
|
||||
margin: 0
|
||||
padding: 0
|
||||
width: 100%
|
||||
display: flex
|
||||
+clearfix
|
||||
|
||||
.search-hit-name
|
||||
font-size: 1.2em
|
||||
li
|
||||
display: inline-block
|
||||
margin: 5px auto
|
||||
|
||||
small
|
||||
margin-left: 5px
|
||||
color: $color-text-dark-secondary
|
||||
&:last-child
|
||||
border-color: transparent
|
||||
|
||||
.search-hit-roles
|
||||
font-size: .9em
|
||||
a
|
||||
font-weight: 500
|
||||
padding: 5px 4px
|
||||
color: $color-text-dark-secondary
|
||||
margin-left: 15px
|
||||
|
||||
&:hover
|
||||
color: $color-text-dark-primary
|
||||
|
||||
&.disabled
|
||||
opacity: .6
|
||||
|
||||
&.active a
|
||||
color: $color-text-dark-primary
|
||||
font-weight: bold
|
||||
|
||||
|
||||
.view-grid
|
||||
display: flex
|
||||
@@ -703,13 +422,13 @@ $search-hit-width_grid: 100px
|
||||
transition: border-color 150ms ease-in-out
|
||||
|
||||
&.active
|
||||
background-color: $color-primary
|
||||
border-color: $color-primary
|
||||
background-color: $primary
|
||||
border-color: $primary
|
||||
|
||||
.search-hit-name
|
||||
font-weight: 500
|
||||
color: white
|
||||
background-color: $color-primary
|
||||
background-color: $primary
|
||||
|
||||
.search-hit-name
|
||||
font-size: .9em
|
||||
@@ -773,5 +492,5 @@ $search-hit-width_grid: 100px
|
||||
|
||||
&.active
|
||||
color: white
|
||||
background-color: $color-primary
|
||||
background-color: $primary
|
||||
border-color: transparent
|
||||
|
@@ -67,131 +67,6 @@
|
||||
&:hover
|
||||
background-color: lighten($provider-color-google, 7%)
|
||||
|
||||
#settings
|
||||
#settings-sidebar
|
||||
+media-xs
|
||||
width: 100%
|
||||
|
||||
+container-box
|
||||
background-color: $color-background-light
|
||||
color: $color-text
|
||||
margin-right: 15px
|
||||
width: 30%
|
||||
|
||||
.settings-content
|
||||
padding: 0
|
||||
|
||||
ul
|
||||
list-style: none
|
||||
margin: 0
|
||||
padding: 0
|
||||
|
||||
a
|
||||
&:hover
|
||||
text-decoration: none
|
||||
|
||||
li
|
||||
background-color: lighten($color-background, 5%)
|
||||
|
||||
li
|
||||
border-bottom: thin solid $color-background
|
||||
border-left: thick solid transparent
|
||||
margin: 0
|
||||
padding: 25px
|
||||
transition: all 100ms ease-in-out
|
||||
|
||||
i
|
||||
font-size: 1.1em
|
||||
padding-right: 15px
|
||||
|
||||
.active
|
||||
li
|
||||
background-color: lighten($color-background, 5%)
|
||||
border-left: thick solid $color-info
|
||||
|
||||
|
||||
#settings-container
|
||||
+media-xs
|
||||
width: 100%
|
||||
|
||||
+container-box
|
||||
background-color: $color-background-light
|
||||
width: 70%
|
||||
|
||||
.settings-header
|
||||
background-color: $color-background
|
||||
border-top-left-radius: 3px
|
||||
border-top-right-radius: 3px
|
||||
|
||||
.settings-title
|
||||
font:
|
||||
size: 1.5em
|
||||
weight: 300
|
||||
padding: 10px 15px 10px 25px
|
||||
|
||||
.settings-content
|
||||
padding: 25px
|
||||
|
||||
.settings-billing-info
|
||||
font-size: 1.2em
|
||||
|
||||
.subscription-active
|
||||
color: $color-success
|
||||
padding-bottom: 20px
|
||||
.subscription-demo
|
||||
color: $color-info
|
||||
margin-top: 0
|
||||
.subscription-missing
|
||||
color: $color-danger
|
||||
margin-top: 0
|
||||
|
||||
.button-submit
|
||||
clear: both
|
||||
display: block
|
||||
min-width: 200px
|
||||
margin: 0 auto
|
||||
+button($color-primary, 3px, true)
|
||||
|
||||
|
||||
#settings-container
|
||||
#settings-form
|
||||
width: 100%
|
||||
|
||||
|
||||
.settings-form
|
||||
align-items: center
|
||||
display: flex
|
||||
justify-content: center
|
||||
|
||||
.left, .right
|
||||
padding: 25px 0
|
||||
|
||||
.left
|
||||
width: 60%
|
||||
float: left
|
||||
|
||||
.right
|
||||
width: 40%
|
||||
float: right
|
||||
text-align: center
|
||||
|
||||
label
|
||||
color: $color-text
|
||||
display: block
|
||||
|
||||
.settings-avatar
|
||||
img
|
||||
border-radius: 3px
|
||||
|
||||
span
|
||||
display: block
|
||||
padding: 15px 0
|
||||
font:
|
||||
size: .9em
|
||||
|
||||
.settings-password
|
||||
color: $color-text-dark-primary
|
||||
|
||||
|
||||
#user-edit-container
|
||||
padding: 15px
|
||||
|
@@ -171,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
|
||||
@@ -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
|
||||
@@ -667,6 +667,9 @@
|
||||
.user-select-none
|
||||
user-select: none
|
||||
|
||||
.pointer-events-none
|
||||
pointer-events: none
|
||||
|
||||
// Bootstrap has .img-fluid, a class to limit the width of an image to 100%.
|
||||
// .imgs-fluid below is to be applied on a parent container when we can't add
|
||||
// classes to the images themselves. e.g. the blog.
|
||||
@@ -674,3 +677,18 @@
|
||||
img
|
||||
// Just re-use Bootstrap's mixin here.
|
||||
+img-fluid
|
||||
|
||||
.overflow-hidden
|
||||
overflow: hidden
|
||||
|
||||
=text-gradient($color_from, $color_to)
|
||||
background: linear-gradient(to right, $color_from, $color_to)
|
||||
background-clip: text
|
||||
-webkit-background-clip: text
|
||||
-webkit-text-fill-color: transparent
|
||||
|
||||
=active-gradient
|
||||
+text-gradient($primary-accent, $primary)
|
||||
|
||||
&:before
|
||||
+text-gradient($primary-accent, $primary)
|
||||
|
@@ -15,93 +15,44 @@
|
||||
|
||||
@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/card"
|
||||
@import "components/jumbotron"
|
||||
@import "components/alerts"
|
||||
@import "components/navbar"
|
||||
@import "components/dropdown"
|
||||
@import "components/footer"
|
||||
@import "components/shortcode"
|
||||
@import "components/statusbar"
|
||||
@import "components/search"
|
||||
@import "components/flyout"
|
||||
@import "components/forms"
|
||||
@import "components/inputs"
|
||||
@import "components/buttons"
|
||||
@import "components/popover"
|
||||
@import "components/tooltip"
|
||||
@import "components/checkbox"
|
||||
@import "components/overlay"
|
||||
@import "components/card"
|
||||
|
||||
@import _comments
|
||||
@import _error
|
||||
@import _search
|
||||
|
||||
@import components/base
|
||||
@import components/alerts
|
||||
@import components/navbar
|
||||
@import components/footer
|
||||
@import components/shortcode
|
||||
@import components/statusbar
|
||||
@import components/search
|
||||
@import components/flyout
|
||||
@import components/forms
|
||||
@import components/inputs
|
||||
@import components/buttons
|
||||
@import components/popover
|
||||
@import components/tooltip
|
||||
@import components/checkbox
|
||||
@import components/overlay
|
||||
|
||||
#blog_container
|
||||
+media-xs
|
||||
flex-direction: column
|
||||
padding-top: 0
|
||||
|
||||
video
|
||||
max-width: 100%
|
||||
@import _notifications
|
||||
|
||||
#blog_post-edit-form
|
||||
padding: 20px
|
||||
|
||||
.form-group
|
||||
position: relative
|
||||
margin: 0 auto 30px auto
|
||||
|
||||
input, textarea, select
|
||||
+input-generic
|
||||
|
||||
@@ -169,7 +120,6 @@
|
||||
margin-bottom: 15px
|
||||
border-top: thin solid $color-text-dark-hint
|
||||
|
||||
|
||||
.form-group.description,
|
||||
.form-group.summary,
|
||||
.form-group.content
|
||||
@@ -237,64 +187,10 @@
|
||||
color: transparent
|
||||
|
||||
|
||||
#blog_post-create-container,
|
||||
#blog_post-edit-container
|
||||
padding: 25px
|
||||
|
||||
.blog_index-item
|
||||
.item-picture
|
||||
position: relative
|
||||
width: 100%
|
||||
max-height: 350px
|
||||
min-height: 200px
|
||||
height: auto
|
||||
overflow: hidden
|
||||
border-top-left-radius: 3px
|
||||
border-top-right-radius: 3px
|
||||
+clearfix
|
||||
|
||||
img
|
||||
+position-center-translate
|
||||
width: 100%
|
||||
border-top-left-radius: 3px
|
||||
border-top-right-radius: 3px
|
||||
|
||||
+media-xs
|
||||
min-height: 150px
|
||||
+media-sm
|
||||
min-height: 150px
|
||||
+media-md
|
||||
min-height: 250px
|
||||
+media-lg
|
||||
min-height: 250px
|
||||
|
||||
.item-content
|
||||
+node-details-description
|
||||
|
||||
+media-xs
|
||||
padding:
|
||||
left: 0
|
||||
right: 0
|
||||
|
||||
img
|
||||
display: block
|
||||
margin: 0 auto
|
||||
|
||||
.item-meta
|
||||
color: $color-text-dark-secondary
|
||||
padding:
|
||||
left: 25px
|
||||
right: 25px
|
||||
|
||||
+media-xs
|
||||
padding:
|
||||
left: 10px
|
||||
right: 10px
|
||||
|
||||
#blog_index-container,
|
||||
#blog_post-create-container,
|
||||
#blog_post-edit-container
|
||||
+container-box
|
||||
padding: 25px
|
||||
width: 75%
|
||||
|
||||
+media-xs
|
||||
@@ -309,11 +205,6 @@
|
||||
+media-lg
|
||||
width: 100%
|
||||
|
||||
|
||||
.item-picture+.button-back+.button-edit
|
||||
right: 20px
|
||||
top: 20px
|
||||
|
||||
#blog_post-edit-form
|
||||
padding: 0
|
||||
|
||||
@@ -348,206 +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-back
|
||||
+button($color-info, 6px, true)
|
||||
display: block
|
||||
width: 100%
|
||||
margin: 15px 0 0 0
|
||||
|
||||
#blog_post-edit-form
|
||||
.form-group
|
||||
.form-control
|
||||
background-color: white
|
||||
|
||||
.blog_index-sidebar,
|
||||
.blog_project-sidebar
|
||||
+container-box
|
||||
background-color: lighten($color-background, 5%)
|
||||
padding: 20px
|
||||
|
||||
.blog_project-card
|
||||
position: relative
|
||||
width: 100%
|
||||
border-radius: 3px
|
||||
overflow: hidden
|
||||
background-color: white
|
||||
color: lighten($color-text, 10%)
|
||||
box-shadow: 0 0 30px rgba(black, .2)
|
||||
|
||||
margin:
|
||||
top: 0
|
||||
bottom: 15px
|
||||
left: auto
|
||||
right: auto
|
||||
|
||||
|
||||
a.item-header
|
||||
position: relative
|
||||
width: 100%
|
||||
height: 100px
|
||||
display: block
|
||||
background-size: 100% 100%
|
||||
|
||||
overflow: hidden
|
||||
|
||||
.overlay
|
||||
z-index: 1
|
||||
width: 100%
|
||||
height: 100px
|
||||
@include overlay(transparent, 0%, white, 100%)
|
||||
|
||||
|
||||
img.background
|
||||
width: 100%
|
||||
transform: scale(1.4)
|
||||
|
||||
.card-thumbnail
|
||||
position: absolute
|
||||
z-index: 2
|
||||
height: 90px
|
||||
width: 90px
|
||||
display: block
|
||||
top: 35px
|
||||
left: 50%
|
||||
transform: translateX(-50%)
|
||||
background-color: white
|
||||
border-radius: 3px
|
||||
overflow: hidden
|
||||
|
||||
&:hover
|
||||
img.thumb
|
||||
opacity: .9
|
||||
|
||||
img.thumb
|
||||
width: 100%
|
||||
border-radius: 3px
|
||||
transition: opacity 150ms ease-in-out
|
||||
+position-center-translate
|
||||
|
||||
.item-info
|
||||
padding: 10px 20px
|
||||
background-color: white
|
||||
border-bottom-left-radius: 3px
|
||||
border-bottom-right-radius: 3px
|
||||
|
||||
a.item-title
|
||||
display: inline-block
|
||||
width: 100%
|
||||
padding: 30px 0 15px 0
|
||||
color: $color-text-dark
|
||||
text-align: center
|
||||
font:
|
||||
size: 1.6em
|
||||
weight: 300
|
||||
|
||||
transition: color 150ms ease-in-out
|
||||
|
||||
&:hover
|
||||
text-decoration: none
|
||||
color: $color-primary
|
||||
|
||||
.blog-archive-navigation
|
||||
+media-xs
|
||||
font-size: 1em
|
||||
max-width: initial
|
||||
|
||||
border-bottom: thin solid $color-background-dark
|
||||
display: flex
|
||||
font:
|
||||
size: 1.2em
|
||||
weight: 300
|
||||
margin: 0 auto
|
||||
max-width: 780px
|
||||
text-align: center
|
||||
+text-overflow-ellipsis
|
||||
|
||||
&:last-child
|
||||
border: none
|
||||
|
||||
a, span
|
||||
+media-xs
|
||||
padding: 10px
|
||||
|
||||
flex: 1
|
||||
padding: 25px 15px
|
||||
|
||||
span
|
||||
color: $color-text-dark-secondary
|
||||
pointer-events: none
|
||||
|
||||
// Specific tweaks for blogs in the context of a project.
|
||||
#project_context
|
||||
.blog_index-item
|
||||
+media-xs
|
||||
margin-left: 0
|
||||
padding: 0
|
||||
margin-left: 10px
|
||||
|
||||
&.list
|
||||
margin-left: 35px !important
|
||||
|
||||
.item-title,
|
||||
.item-info
|
||||
+media-xs
|
||||
padding-left: 0
|
||||
padding-left: 25px
|
||||
|
||||
#blog_container
|
||||
.comments-container
|
||||
+media-sm
|
||||
margin-left: 10px
|
||||
margin-left: 30px
|
||||
|
||||
.blog-archive-navigation
|
||||
margin-left: 35px
|
||||
|
||||
// Used on the blog.
|
||||
.comments-compact
|
||||
.comments-list
|
||||
border: none
|
||||
padding: 0 0 15px 0
|
||||
|
||||
.comments-container
|
||||
max-width: 680px
|
||||
margin: 0 auto
|
||||
|
||||
.comment-reply-container
|
||||
background-color: transparent
|
||||
|
||||
.comment-reply-field
|
||||
textarea, .comment-reply-meta
|
||||
background-color: $color-background-light
|
||||
|
||||
&.filled
|
||||
.comment-reply-meta
|
||||
background-color: $color-success
|
||||
|
||||
.comment-reply-form
|
||||
+media-xs
|
||||
padding:
|
||||
left: 0
|
||||
|
@@ -1,23 +1,132 @@
|
||||
.card-deck
|
||||
// Custom, as of bootstrap 4.1.3 there is no way to do this.
|
||||
&.card-3-columns
|
||||
.card
|
||||
min-width: 30%
|
||||
max-width: 30%
|
||||
&.card-deck-responsive
|
||||
@extend .row
|
||||
|
||||
.card
|
||||
@extend .col-md-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
|
||||
border: none
|
||||
@extend .border-0
|
||||
|
||||
.card-body
|
||||
padding: 15px 0
|
||||
@extend .px-0
|
||||
|
||||
.card-fade
|
||||
img
|
||||
opacity: .8
|
||||
transition: opacity ease-in-out 150ms
|
||||
|
||||
.card-image-fade
|
||||
&:hover
|
||||
.card-img-top
|
||||
opacity: .9
|
||||
|
||||
|
||||
.card.asset
|
||||
color: $color-text
|
||||
|
||||
&:hover
|
||||
img
|
||||
opacity: 1
|
||||
text-decoration: none
|
||||
|
||||
&.free
|
||||
overflow: hidden
|
||||
|
||||
&:after
|
||||
+ribbon
|
||||
content: 'FREE'
|
||||
padding: 2px 50px
|
||||
|
||||
.card-body
|
||||
position: relative // for placing the progress
|
||||
|
||||
.card-text
|
||||
font-size: $font-size-xs
|
||||
|
||||
.card-img-top
|
||||
background-color: $color-background
|
||||
background-size: cover
|
||||
background-position: center
|
||||
|
||||
|
||||
$card-progress-height: 5px
|
||||
.progress
|
||||
height: $card-progress-height
|
||||
position: absolute
|
||||
top: -$card-progress-height
|
||||
width: 100%
|
||||
|
||||
.card-img-top
|
||||
&.card-icon
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
font-size: 2em
|
||||
|
||||
i
|
||||
opacity: .2
|
||||
|
||||
/* Tiny label for cards. e.g. 'WATCHED' on videos. */
|
||||
.card-label
|
||||
background-color: rgba($black, .5)
|
||||
border-radius: 3px
|
||||
color: $white
|
||||
display: block
|
||||
font-size: $font-size-xxs
|
||||
left: 5px
|
||||
top: -27px // enough to be above the progress-bar
|
||||
position: absolute
|
||||
padding: 1px 5px
|
||||
z-index: 1
|
||||
|
||||
.card
|
||||
&.active
|
||||
.card-title
|
||||
color: $primary
|
||||
|
@@ -1,13 +1,44 @@
|
||||
// Global, we want all menus to look like this.
|
||||
.dropdown-menu
|
||||
ul.dropdown-menu
|
||||
box-shadow: $dropdown-box-shadow
|
||||
top: 95% // So there is less gap between the dropdown and the item.
|
||||
|
||||
> li
|
||||
&:first-child > a
|
||||
padding-top: ($dropdown-item-padding-y * 1.5)
|
||||
&:last-child > a
|
||||
padding-bottom: ($dropdown-item-padding-y * 1.5)
|
||||
|
||||
> a
|
||||
padding: $dropdown-item-padding-y
|
||||
padding-top: $dropdown-item-padding-y
|
||||
padding-bottom: $dropdown-item-padding-y
|
||||
|
||||
.dropdown-divider
|
||||
margin: 0
|
||||
|
||||
.dropdown-item:last-child
|
||||
border-bottom-left-radius: $border-radius
|
||||
border-bottom-right-radius: $border-radius
|
||||
|
||||
// Open dropdown on mouse hover dropdowns in the navbar.
|
||||
nav .dropdown:hover
|
||||
ul.dropdown-menu
|
||||
display: block
|
||||
|
||||
nav .dropdown.large:hover
|
||||
.dropdown-menu
|
||||
@extend .d-flex
|
||||
|
||||
.dropdown.large.show
|
||||
@extend .d-flex
|
||||
|
||||
.dropdown-menu.show
|
||||
@extend .d-flex
|
||||
|
||||
.dropdown-menu-tab
|
||||
display: none
|
||||
min-width: 100px
|
||||
|
||||
|
||||
&.show // .dropdown-menu-tab.show
|
||||
@extend .d-flex
|
||||
|
@@ -20,6 +20,6 @@
|
||||
overflow-x: hidden
|
||||
position: absolute
|
||||
right: 0
|
||||
top: 60px
|
||||
top: 40px
|
||||
width: 420px
|
||||
z-index: 9999
|
||||
|
@@ -1,25 +1,41 @@
|
||||
// Mainly overrides bootstrap jumbotron settings
|
||||
.jumbotron
|
||||
@extend .d-flex
|
||||
@extend .mb-0
|
||||
@extend .rounded-0
|
||||
background-size: cover
|
||||
border-radius: 0
|
||||
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
|
||||
position: relative
|
||||
|
||||
&:after
|
||||
background-image: linear-gradient(45deg, rgba(black, .5) 25%, transparent 50%)
|
||||
bottom: 0
|
||||
content: ''
|
||||
left: 0
|
||||
position: absolute
|
||||
right: 0
|
||||
top: 0
|
||||
|
||||
*
|
||||
z-index: 1
|
||||
&:after
|
||||
display: block
|
||||
visibility: visible
|
||||
|
||||
&.jumbotron-overlay-gradient
|
||||
*
|
||||
z-index: 1
|
||||
&:after
|
||||
background-color: transparent
|
||||
background-image: linear-gradient(45deg, rgba(black, .5) 25%, transparent 50%)
|
||||
display: block
|
||||
visibility: visible
|
||||
|
||||
h2, p
|
||||
text-shadow: 1px 1px rgba(black, .2), 1px 1px 25px rgba(black, .5)
|
||||
|
@@ -1,14 +1,12 @@
|
||||
// Navigation.
|
||||
|
||||
/* Top level navigation bar. */
|
||||
.navbar
|
||||
box-shadow: inset 0 -2px $color-background
|
||||
|
||||
.navbar,
|
||||
nav.sidebar
|
||||
.nav
|
||||
border: none
|
||||
color: $color-text-dark-secondary
|
||||
padding: 0
|
||||
z-index: $z-index-base + 5 /* Flowplayer seems to take up to 11, project container is 12 */
|
||||
z-index: $z-index-base + 5
|
||||
|
||||
nav
|
||||
margin-left: auto
|
||||
@@ -20,52 +18,27 @@ nav.sidebar
|
||||
margin: 0
|
||||
width: 100%
|
||||
|
||||
.navbar-item
|
||||
align-items: center
|
||||
display: flex
|
||||
user-select: none
|
||||
color: inherit
|
||||
|
||||
+media-sm
|
||||
padding-left: 10px
|
||||
padding-right: 10px
|
||||
|
||||
&:hover, &:focus
|
||||
color: $primary
|
||||
background-color: transparent
|
||||
box-shadow: inset 0 -3px 0 $primary
|
||||
text-decoration: none
|
||||
|
||||
&:focus
|
||||
box-shadow: inset 0 -3px 0 $primary
|
||||
|
||||
&.active
|
||||
color: $primary
|
||||
box-shadow: inset 0 -3px 0 $primary
|
||||
|
||||
li
|
||||
user-select: none
|
||||
position: relative
|
||||
|
||||
img.gravatar
|
||||
border-radius: 999em
|
||||
height: 32px
|
||||
width: 32px
|
||||
box-shadow: 1px 1px 0 rgba(black, .2)
|
||||
height: 28px
|
||||
position: relative
|
||||
width: 28px
|
||||
|
||||
.special
|
||||
width: 18px
|
||||
height: 18px
|
||||
border-radius: 999em
|
||||
position: absolute
|
||||
background-color: white
|
||||
z-index: 2
|
||||
display: inline-block
|
||||
top: 10px
|
||||
left: 38px
|
||||
font-size: 1.2em
|
||||
border-radius: 999em
|
||||
box-shadow: 1px 1px 1px rgba(black, .2)
|
||||
display: inline-block
|
||||
font-size: 1.2em
|
||||
height: 18px
|
||||
left: 28px
|
||||
position: absolute
|
||||
top: 3px
|
||||
width: 18px
|
||||
z-index: 2
|
||||
|
||||
&.subscriber
|
||||
background-color: $color-success
|
||||
@@ -83,64 +56,146 @@ nav.sidebar
|
||||
i
|
||||
+position-center-translate
|
||||
|
||||
.dropdown
|
||||
min-width: 60px // navbar avatar size
|
||||
.dropdown
|
||||
.navbar-item
|
||||
&:hover
|
||||
box-shadow: none // Remove the blue underline usually on navbar, from dropdown items.
|
||||
|
||||
span.fa-stack
|
||||
position: absolute
|
||||
top: 50%
|
||||
left: 50%
|
||||
transform: translate(-50%, -50%)
|
||||
ul.dropdown-menu
|
||||
li
|
||||
a
|
||||
white-space: nowrap
|
||||
|
||||
ul.dropdown-menu
|
||||
li
|
||||
a
|
||||
white-space: nowrap
|
||||
.subitem // e.g. "Not Sintel? Log out"
|
||||
font-size: .8em
|
||||
text-transform: initial
|
||||
|
||||
&:hover
|
||||
box-shadow: none // removes underline
|
||||
i
|
||||
width: 30px
|
||||
|
||||
&.subitem // e.g. "Not Sintel? Log out"
|
||||
font-size: .8em
|
||||
padding-top: 0
|
||||
text-transform: initial
|
||||
&.subscription-status
|
||||
a, a:hover
|
||||
color: $white
|
||||
|
||||
i
|
||||
width: 30px
|
||||
&.none
|
||||
background-color: $color-danger
|
||||
|
||||
&.subscription-status
|
||||
&.none a
|
||||
color: $color-danger
|
||||
&.subscriber
|
||||
background-color: $color-success
|
||||
|
||||
&.subscriber a
|
||||
color: $color-success
|
||||
&.demo
|
||||
background-color: $color-info
|
||||
|
||||
&.demo a
|
||||
color: $color-info
|
||||
span.info
|
||||
display: block
|
||||
|
||||
span.info
|
||||
span.renew
|
||||
display: block
|
||||
|
||||
span.renew
|
||||
display: block
|
||||
color: $color-text-dark-primary
|
||||
font-size: .9em
|
||||
font-size: .9em
|
||||
|
||||
|
||||
// Secondary navigation for
|
||||
.nav-link
|
||||
@extend .d-flex
|
||||
|
||||
.nav-title
|
||||
white-space: nowrap
|
||||
|
||||
.navbar-item
|
||||
align-items: center
|
||||
display: flex
|
||||
user-select: none
|
||||
color: inherit
|
||||
|
||||
+media-sm
|
||||
padding-left: 10px
|
||||
padding-right: 10px
|
||||
|
||||
&:hover, &:focus
|
||||
color: $primary
|
||||
background-color: transparent
|
||||
box-shadow: inset 0 -3px 0 $primary
|
||||
text-decoration: none
|
||||
|
||||
&:focus
|
||||
box-shadow: inset 0 -3px 0 $primary
|
||||
|
||||
&.active
|
||||
color: $primary
|
||||
box-shadow: inset 0 -3px 0 $primary
|
||||
|
||||
|
||||
/* Secondary navigation. */
|
||||
$nav-secondary-bar-size: -2px
|
||||
.nav-secondary
|
||||
align-items: center
|
||||
box-shadow: inset 0 -2px 0 0 $color-background
|
||||
box-shadow: inset 0 $nav-secondary-bar-size 0 0 $color-background
|
||||
|
||||
.nav-link
|
||||
color: $color-text
|
||||
cursor: pointer
|
||||
transition: box-shadow 150ms ease-in-out
|
||||
transition: color 150ms ease-in-out
|
||||
|
||||
&:hover,
|
||||
&.active
|
||||
box-shadow: inset 0 -2px 0 0 $primary
|
||||
&:after
|
||||
background-color: transparent
|
||||
bottom: 0
|
||||
content: ''
|
||||
height: 2px
|
||||
position: absolute
|
||||
right: 0
|
||||
left: 0
|
||||
width: 0
|
||||
transition: width 150ms ease-in-out
|
||||
|
||||
.nav-link:hover,
|
||||
.nav-link.active,
|
||||
.nav-item.dropdown.show > .nav-link
|
||||
// Blue bar on the bottom.
|
||||
&:after
|
||||
background-color: $primary-accent
|
||||
background-image: linear-gradient(to right, $primary-accent 70%, $primary)
|
||||
height: 2px
|
||||
width: 100%
|
||||
|
||||
span
|
||||
+active-gradient
|
||||
|
||||
i
|
||||
color: $primary-accent
|
||||
|
||||
&.nav-secondary-vertical
|
||||
align-items: flex-start
|
||||
flex-direction: column
|
||||
box-shadow: none // Last item on the list already has a box-shadow.
|
||||
|
||||
> li
|
||||
width: 100% // span across the whole width.
|
||||
|
||||
// Blue bar on the side.
|
||||
.nav-link
|
||||
&:hover,
|
||||
&.active
|
||||
color: $primary
|
||||
@extend .bg-white
|
||||
|
||||
&:after
|
||||
background-image: linear-gradient($primary-accent 70%, $primary)
|
||||
height: 100%
|
||||
left: initial
|
||||
top: 0
|
||||
width: 3px
|
||||
|
||||
// Big navigation dropdown.
|
||||
.nav-main
|
||||
.nav-secondary
|
||||
.nav-link
|
||||
@extend .pr-5
|
||||
box-shadow: none
|
||||
|
||||
&.nav-see-more
|
||||
color: $primary
|
||||
|
||||
i, span
|
||||
+active-gradient
|
||||
|
||||
.navbar-overlay
|
||||
+media-lg
|
||||
@@ -160,13 +215,6 @@ nav.sidebar
|
||||
background-color: $color-background-nav
|
||||
text-shadow: none
|
||||
|
||||
.navbar-brand
|
||||
color: inherit
|
||||
padding-left: 4px
|
||||
|
||||
&:hover
|
||||
color: $primary
|
||||
|
||||
nav.navbar
|
||||
.navbar-collapse
|
||||
> ul > li > .navbar-item
|
||||
|
@@ -3,8 +3,33 @@
|
||||
/* Makes it possible to override the path before importing font-pillar.sass */
|
||||
$pillar-font-path: "../font" !default
|
||||
|
||||
/* Font aliases */
|
||||
.pi /* blank item with the right spacing */
|
||||
/* Font properties. */
|
||||
@font-face
|
||||
font-family: 'pillar-font'
|
||||
src: url('#{$pillar-font-path}/pillar-font.woff?54788822') format("woff"), url('#{$pillar-font-path}/pillar-font.woff2?54788822') format("woff2")
|
||||
font-weight: normal
|
||||
font-style: normal
|
||||
|
||||
[class^="pi-"]:before, [class*=" pi-"]:before
|
||||
font-family: "pillar-font"
|
||||
font-style: normal
|
||||
font-weight: normal
|
||||
speak: none
|
||||
display: inline-block
|
||||
text-decoration: inherit
|
||||
width: 1em
|
||||
margin-right: .2em
|
||||
text-align: center
|
||||
font-variant: normal
|
||||
text-transform: none
|
||||
line-height: 1em
|
||||
margin-left: .2em
|
||||
-webkit-font-smoothing: antialiased
|
||||
-moz-osx-font-smoothing: grayscale
|
||||
|
||||
/* Icon aliases. */
|
||||
/* Empty icons, multiple names for the same/unasigned icon, etc. */
|
||||
.pi, .pi-blank
|
||||
&:after
|
||||
content: ''
|
||||
font-family: "pillar-font"
|
||||
@@ -14,12 +39,10 @@ $pillar-font-path: "../font" !default
|
||||
display: inline-block
|
||||
text-decoration: inherit
|
||||
width: 1em
|
||||
margin-right: .2em
|
||||
text-align: center
|
||||
font-variant: normal
|
||||
text-transform: none
|
||||
line-height: 1em
|
||||
margin-left: .2em
|
||||
-webkit-font-smoothing: antialiased
|
||||
-moz-osx-font-smoothing: grayscale
|
||||
position: relative
|
||||
@@ -27,10 +50,31 @@ $pillar-font-path: "../font" !default
|
||||
&:before
|
||||
position: relative
|
||||
|
||||
.pi-svnman:before
|
||||
content: '\f1c0'
|
||||
|
||||
/* Assets */
|
||||
.pi-group
|
||||
@extend .pi-folder
|
||||
.pi-video
|
||||
@extend .pi-film-thick
|
||||
.pi-file
|
||||
@extend .pi-file-archive
|
||||
.pi-asset
|
||||
@extend .pi-file-archive
|
||||
.pi-group_texture
|
||||
@extend .pi-folder-texture
|
||||
.pi-post
|
||||
@extend .pi-newspaper
|
||||
.pi-page
|
||||
@extend .pi-document
|
||||
|
||||
/* License */
|
||||
.pi-license-cc-zero:before
|
||||
content: '\e85a'
|
||||
.pi-license-cc-sa:before
|
||||
content: '\e858'
|
||||
top: 1px
|
||||
.pi-license-cc-nd:before
|
||||
content: '\e859'
|
||||
.pi-license-cc-nc:before
|
||||
@@ -59,47 +103,17 @@ $pillar-font-path: "../font" !default
|
||||
&:before
|
||||
left: 27px
|
||||
|
||||
|
||||
/*
|
||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
* Here begins the CSS code generated by fontello.com, converted to *
|
||||
* Sass and replaced the path with our variable $pillar-font-path. *
|
||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
* Here begins the CSS code generated by fontello.com by using *
|
||||
* the config.json file in /pillar/web/static/assets/font *
|
||||
* Just convert the icon classes from pillar-font.css to Sass *
|
||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
* When adding icons, only add/overwrite icon classes e.g. .pi-bla *
|
||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
*/
|
||||
|
||||
@font-face
|
||||
font-family: 'pillar-font'
|
||||
src: url('#{$pillar-font-path}/pillar-font.woff?55726379') format("woff"), url('#{$pillar-font-path}/pillar-font.woff2?55726379') format("woff2")
|
||||
font-weight: normal
|
||||
font-style: normal
|
||||
|
||||
[class^="pi-"]:before, [class*=" pi-"]:before
|
||||
font-family: "pillar-font"
|
||||
font-style: normal
|
||||
font-weight: normal
|
||||
speak: none
|
||||
display: inline-block
|
||||
text-decoration: inherit
|
||||
width: 1em
|
||||
margin-right: .2em
|
||||
text-align: center
|
||||
|
||||
/* opacity: .8;
|
||||
/* For safety - reset parent styles, that can break glyph codes
|
||||
font-variant: normal
|
||||
text-transform: none
|
||||
/* fix buttons height, for twitter bootstrap
|
||||
line-height: 1em
|
||||
/* Animation center compensation - margins should be symmetric
|
||||
/* remove if not needed
|
||||
margin-left: .2em
|
||||
/* you can be more comfortable with increased icons size
|
||||
/* font-size: 120%;
|
||||
/* Font smoothing. That was taken from TWBS
|
||||
-webkit-font-smoothing: antialiased
|
||||
-moz-osx-font-smoothing: grayscale
|
||||
/* Uncomment for 3D effect
|
||||
/* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3);
|
||||
|
||||
.pi-collection-plus:before
|
||||
content: '\e800'
|
||||
|
||||
@@ -495,6 +509,11 @@ $pillar-font-path: "../font" !default
|
||||
|
||||
/* ''
|
||||
|
||||
.pi-speed:before
|
||||
content: '\e84f'
|
||||
|
||||
/* ''
|
||||
|
||||
.pi-attention:before
|
||||
content: '\e850'
|
||||
|
||||
@@ -645,11 +664,6 @@ $pillar-font-path: "../font" !default
|
||||
|
||||
/* ''
|
||||
|
||||
.pi-users:before
|
||||
content: '\e86e'
|
||||
|
||||
/* ''
|
||||
|
||||
.pi-flamenco:before
|
||||
content: '\e86f'
|
||||
|
||||
@@ -670,6 +684,11 @@ $pillar-font-path: "../font" !default
|
||||
|
||||
/* ''
|
||||
|
||||
.pi-users:before
|
||||
content: '\e873'
|
||||
|
||||
/* ''
|
||||
|
||||
.pi-pause:before
|
||||
content: '\f00e'
|
||||
|
||||
@@ -705,6 +724,16 @@ $pillar-font-path: "../font" !default
|
||||
|
||||
/* ''
|
||||
|
||||
.pi-social-instagram:before
|
||||
content: '\f16d'
|
||||
|
||||
/* ''
|
||||
|
||||
.pi-database:before
|
||||
content: '\f1c0'
|
||||
|
||||
/* ''
|
||||
|
||||
.pi-newspaper:before
|
||||
content: '\f1ea'
|
||||
|
||||
|
@@ -33,11 +33,10 @@ $tree-color-highlight-background-text: $primary
|
||||
|
||||
&[data-node-type="page"],
|
||||
&[data-node-type="blog"]
|
||||
color: darken($tree-color-highlight, 5%)
|
||||
font-weight: bold
|
||||
|
||||
.jstree-anchor
|
||||
padding: 5px 8px 1px 8px
|
||||
padding: 0 6px
|
||||
|
||||
&:after
|
||||
top: 3px !important
|
||||
|
@@ -1,5 +1,7 @@
|
||||
$videoplayer-controls-color: white
|
||||
$videoplayer-background-color: $black
|
||||
$videoplayer-background-color: darken($primary, 10%)
|
||||
|
||||
$videoplayer-progress-bar-height: .5em
|
||||
|
||||
.video-js
|
||||
.vjs-big-play-button:before, .vjs-control:before, .vjs-modal-dialog
|
||||
@@ -30,7 +32,6 @@ $videoplayer-background-color: $black
|
||||
font-weight: normal
|
||||
font-style: normal
|
||||
|
||||
|
||||
.vjs-icon-play
|
||||
font-family: VideoJS
|
||||
font-weight: normal
|
||||
@@ -285,7 +286,6 @@ $videoplayer-background-color: $black
|
||||
line-height: 1
|
||||
font-weight: normal
|
||||
font-style: normal
|
||||
font-family: Arial, Helvetica, sans-serif
|
||||
-webkit-user-select: none
|
||||
-moz-user-select: none
|
||||
-ms-user-select: none
|
||||
@@ -453,20 +453,22 @@ body.vjs-full-window
|
||||
list-style: none
|
||||
margin: 0
|
||||
padding: 0.2em 0
|
||||
line-height: 1.4em
|
||||
font-size: 1.2em
|
||||
line-height: 1.8em
|
||||
font-size: 1.1em
|
||||
text-align: center
|
||||
text-transform: lowercase
|
||||
|
||||
&:focus, &:hover
|
||||
outline: 0
|
||||
background-color: #73859f
|
||||
background-color: rgba(115, 133, 159, 0.5)
|
||||
background-color: darken($primary, 20%)
|
||||
|
||||
&.vjs-selected
|
||||
background-color: $videoplayer-controls-color
|
||||
color: $videoplayer-background-color
|
||||
|
||||
&:focus, &:hover
|
||||
background-color: $videoplayer-controls-color
|
||||
color: $videoplayer-background-color
|
||||
|
||||
&.vjs-menu-title
|
||||
text-align: center
|
||||
text-transform: uppercase
|
||||
@@ -486,12 +488,13 @@ body.vjs-full-window
|
||||
height: 0em
|
||||
margin-bottom: 1.5em
|
||||
border-top-color: $videoplayer-background-color
|
||||
|
||||
.vjs-menu-content
|
||||
background-color: $videoplayer-background-color
|
||||
position: absolute
|
||||
width: 100%
|
||||
bottom: 1.5em
|
||||
max-height: 15em
|
||||
max-height: 25em
|
||||
|
||||
.vjs-workinghover .vjs-menu-button-popup:hover .vjs-menu, .vjs-menu-button-popup .vjs-menu.vjs-lock-showing
|
||||
display: block
|
||||
@@ -655,12 +658,12 @@ body.vjs-full-window
|
||||
-moz-transition: all 0.2s
|
||||
-o-transition: all 0.2s
|
||||
transition: all 0.2s
|
||||
height: 0.3em
|
||||
height: $videoplayer-progress-bar-height
|
||||
|
||||
.vjs-play-progress
|
||||
position: absolute
|
||||
display: block
|
||||
height: 0.3em
|
||||
height: $videoplayer-progress-bar-height
|
||||
margin: 0
|
||||
padding: 0
|
||||
width: 0
|
||||
@@ -670,7 +673,7 @@ body.vjs-full-window
|
||||
.vjs-load-progress
|
||||
position: absolute
|
||||
display: block
|
||||
height: 0.3em
|
||||
height: $videoplayer-progress-bar-height
|
||||
margin: 0
|
||||
padding: 0
|
||||
width: 0
|
||||
@@ -680,7 +683,7 @@ body.vjs-full-window
|
||||
div
|
||||
position: absolute
|
||||
display: block
|
||||
height: 0.3em
|
||||
height: $videoplayer-progress-bar-height
|
||||
margin: 0
|
||||
padding: 0
|
||||
width: 0
|
||||
@@ -692,10 +695,11 @@ body.vjs-full-window
|
||||
|
||||
.vjs-play-progress
|
||||
background-color: $videoplayer-controls-color
|
||||
border-radius: 999em
|
||||
|
||||
&:before
|
||||
position: absolute
|
||||
top: -0.333333333333333em
|
||||
top: -($videoplayer-progress-bar-height / 2) // halfway the height of the progress bar
|
||||
right: -0.5em
|
||||
|
||||
&:after
|
||||
@@ -712,8 +716,8 @@ body.vjs-full-window
|
||||
z-index: 1
|
||||
|
||||
.vjs-time-tooltip
|
||||
background-color: $videoplayer-background-color
|
||||
color: $videoplayer-controls-color
|
||||
background-color: $videoplayer-background-color
|
||||
z-index: 1
|
||||
|
||||
&:after
|
||||
@@ -735,9 +739,9 @@ body.vjs-full-window
|
||||
|
||||
.vjs-time-tooltip
|
||||
background-color: $videoplayer-controls-color
|
||||
border-radius: 3px
|
||||
border-radius: $border-radius
|
||||
color: $videoplayer-background-color
|
||||
font-family: $font-body
|
||||
font-family: $font-family-base
|
||||
font-size: 1.2em
|
||||
font-weight: bold
|
||||
padding: 5px 8px
|
||||
@@ -851,9 +855,9 @@ body.vjs-full-window
|
||||
font-size: 0.9em
|
||||
|
||||
.vjs-slider-horizontal .vjs-volume-level
|
||||
height: 0.3em
|
||||
height: $videoplayer-progress-bar-height
|
||||
&:before
|
||||
top: -0.3em
|
||||
top: -$videoplayer-progress-bar-height
|
||||
right: -0.5em
|
||||
|
||||
.vjs-menu-button-popup
|
||||
@@ -1022,14 +1026,15 @@ video::-webkit-media-text-track-display
|
||||
|
||||
.vjs-playback-rate
|
||||
.vjs-playback-rate-value
|
||||
font-size: 1.5em
|
||||
font-size: 1.25em
|
||||
line-height: 2
|
||||
position: absolute
|
||||
top: 0
|
||||
top: 3px
|
||||
left: 0
|
||||
width: 100%
|
||||
height: 100%
|
||||
text-align: center
|
||||
|
||||
.vjs-menu
|
||||
width: 4em
|
||||
left: 0em
|
||||
@@ -1041,7 +1046,6 @@ video::-webkit-media-text-track-display
|
||||
&:before
|
||||
color: $videoplayer-controls-color
|
||||
content: 'X'
|
||||
font-family: Arial, Helvetica, sans-serif
|
||||
font-size: 4em
|
||||
left: 0
|
||||
line-height: 1
|
||||
|
@@ -1,82 +0,0 @@
|
||||
// Bootstrap variables and utilities.
|
||||
@import "../../node_modules/bootstrap/scss/functions"
|
||||
@import "../../node_modules/bootstrap/scss/variables"
|
||||
@import "../../node_modules/bootstrap/scss/mixins"
|
||||
|
||||
@import _config
|
||||
@import _utils
|
||||
|
||||
// Bootstrap components.
|
||||
@import "../../node_modules/bootstrap/scss/root"
|
||||
@import "../../node_modules/bootstrap/scss/reboot"
|
||||
|
||||
@import "../../node_modules/bootstrap/scss/type"
|
||||
@import "../../node_modules/bootstrap/scss/images"
|
||||
|
||||
@import "../../node_modules/bootstrap/scss/code"
|
||||
@import "../../node_modules/bootstrap/scss/grid"
|
||||
@import "../../node_modules/bootstrap/scss/tables"
|
||||
@import "../../node_modules/bootstrap/scss/forms"
|
||||
@import "../../node_modules/bootstrap/scss/buttons"
|
||||
@import "../../node_modules/bootstrap/scss/transitions"
|
||||
@import "../../node_modules/bootstrap/scss/dropdown"
|
||||
@import "../../node_modules/bootstrap/scss/button-group"
|
||||
@import "../../node_modules/bootstrap/scss/input-group"
|
||||
@import "../../node_modules/bootstrap/scss/custom-forms"
|
||||
|
||||
@import "../../node_modules/bootstrap/scss/nav"
|
||||
@import "../../node_modules/bootstrap/scss/navbar"
|
||||
|
||||
@import "../../node_modules/bootstrap/scss/card"
|
||||
@import "../../node_modules/bootstrap/scss/breadcrumb"
|
||||
@import "../../node_modules/bootstrap/scss/pagination"
|
||||
@import "../../node_modules/bootstrap/scss/badge"
|
||||
@import "../../node_modules/bootstrap/scss/jumbotron"
|
||||
@import "../../node_modules/bootstrap/scss/alert"
|
||||
@import "../../node_modules/bootstrap/scss/progress"
|
||||
@import "../../node_modules/bootstrap/scss/media"
|
||||
@import "../../node_modules/bootstrap/scss/list-group"
|
||||
@import "../../node_modules/bootstrap/scss/close"
|
||||
@import "../../node_modules/bootstrap/scss/modal"
|
||||
@import "../../node_modules/bootstrap/scss/tooltip"
|
||||
@import "../../node_modules/bootstrap/scss/popover"
|
||||
@import "../../node_modules/bootstrap/scss/carousel"
|
||||
|
||||
@import "../../node_modules/bootstrap/scss/utilities"
|
||||
@import "../../node_modules/bootstrap/scss/print"
|
||||
|
||||
// Pillar components.
|
||||
@import "apps_base"
|
||||
@import "components/base"
|
||||
|
||||
@import "components/jumbotron"
|
||||
@import "components/alerts"
|
||||
@import "components/navbar"
|
||||
@import "components/dropdown"
|
||||
@import "components/footer"
|
||||
@import "components/shortcode"
|
||||
@import "components/statusbar"
|
||||
@import "components/search"
|
||||
|
||||
@import "components/flyout"
|
||||
@import "components/forms"
|
||||
@import "components/inputs"
|
||||
@import "components/buttons"
|
||||
@import "components/popover"
|
||||
@import "components/tooltip"
|
||||
@import "components/checkbox"
|
||||
@import "components/overlay"
|
||||
@import "components/card"
|
||||
|
||||
@import _notifications
|
||||
@import _comments
|
||||
|
||||
@import _project
|
||||
@import _project-sharing
|
||||
@import _project-dashboard
|
||||
@import _error
|
||||
|
||||
@import _search
|
||||
|
||||
@import plugins/_jstree
|
||||
@import plugins/_js_select2
|
@@ -6,14 +6,15 @@
|
||||
| {% if node_type_name == 'group' %}
|
||||
| {% set node_type_name = 'folder' %}
|
||||
| {% endif %}
|
||||
li.dropdown-item(class="button-{{ node_type['name'] }}")
|
||||
a.item_add_node(
|
||||
li(class="button-{{ node_type['name'] }}")
|
||||
a.dropdown-item(
|
||||
class="item_add_node",
|
||||
href="#",
|
||||
title="{{ node_type['description'] }}",
|
||||
data-node-type-name="{{ node_type['name'] }}",
|
||||
data-toggle="tooltip",
|
||||
data-placement="left")
|
||||
i.pi(class="icon-{{ node_type['name'] }}")
|
||||
i.pi(class="pi-{{ node_type['name'] }}")
|
||||
| {% if node_type_name == 'group_texture' %}
|
||||
| Texture Folder
|
||||
| {% elif node_type_name == 'group_hdri' %}
|
||||
|
50
src/templates/_macros/_asset_list_item.pug
Normal file
50
src/templates/_macros/_asset_list_item.pug
Normal 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 %}
|
@@ -29,32 +29,20 @@ html(lang="en")
|
||||
meta(name="twitter:image", content="")
|
||||
| {% endblock %}
|
||||
|
||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery-3.1.0.min.js')}}")
|
||||
script(src="{{ url_for('static_pillar', filename='assets/js/tutti.min.js') }}")
|
||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.typeahead-0.11.1.min.js')}}")
|
||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/js.cookie-2.0.3.min.js')}}")
|
||||
| {% if current_user.is_authenticated %}
|
||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/clipboard.min.js')}}")
|
||||
| {% endif %}
|
||||
|
||||
script.
|
||||
|
||||
!function(e){"use strict";e.loadCSS=function(t,n,o){var r,i=e.document,l=i.createElement("link");if(n)r=n;else{var d=(i.body||i.getElementsByTagName("head")[0]).childNodes;r=d[d.length-1]}var a=i.styleSheets;l.rel="stylesheet",l.href=t,l.media="only x",r.parentNode.insertBefore(l,n?r:r.nextSibling);var f=function(e){for(var t=l.href,n=a.length;n--;)if(a[n].href===t)return e();setTimeout(function(){f(e)})};return l.onloadcssdefined=f,f(function(){l.media=o||"all"}),l},"undefined"!=typeof module&&(module.exports=e.loadCSS)}(this);
|
||||
|
||||
loadCSS( "//fonts.googleapis.com/css?family=Roboto:300,400" );
|
||||
|
||||
script(src="{{ url_for('static_pillar', filename='assets/js/markdown.min.js') }}")
|
||||
script(src="{{ url_for('static_pillar', filename='assets/js/tutti.min.js') }}")
|
||||
|
||||
link(href="{{ url_for('static', filename='assets/img/favicon.png') }}", rel="shortcut icon")
|
||||
link(href="{{ url_for('static', filename='assets/img/apple-touch-icon-precomposed.png') }}", rel="icon apple-touch-icon-precomposed", sizes="192x192")
|
||||
|
||||
link(href="{{ url_for('static_pillar', filename='assets/css/vendor/bootstrap.min.css') }}", rel="stylesheet")
|
||||
|
||||
| {% block head %}{% endblock %}
|
||||
|
||||
| {% block css %}
|
||||
link(href="{{ url_for('static_pillar', filename='assets/css/font-pillar.css') }}", rel="stylesheet")
|
||||
link(href="{{ url_for('static_pillar', filename='assets/css/base.css') }}", rel="stylesheet")
|
||||
| {% if title == 'blog' %}
|
||||
link(href="{{ url_for('static_pillar', filename='assets/css/blog.css') }}", rel="stylesheet")
|
||||
| {% else %}
|
||||
@@ -83,8 +71,6 @@ html(lang="en")
|
||||
| {% endblock footer %}
|
||||
| {% endblock footer_container%}
|
||||
|
||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.bootstrap-3.3.7.min.js') }}")
|
||||
|
||||
| {% if current_user.is_authenticated %}
|
||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.typewatch-3.0.0.min.js') }}")
|
||||
script.
|
||||
|
@@ -1,10 +1,11 @@
|
||||
| {% if current_user.is_authenticated %}
|
||||
|
||||
li.nav-notifications
|
||||
a.navbar-item#notifications-toggle(
|
||||
title="Notifications",
|
||||
data-toggle="tooltip",
|
||||
data-placement="bottom")
|
||||
li.nav-notifications.nav-item
|
||||
a.nav-link.px-2(
|
||||
id="notifications-toggle",
|
||||
title="Notifications",
|
||||
data-toggle="tooltip",
|
||||
data-placement="bottom")
|
||||
i.pi-notifications-none.nav-notifications-icon
|
||||
span#notifications-count
|
||||
span
|
||||
|
@@ -12,30 +12,11 @@ li.dropdown
|
||||
ul.dropdown-menu.dropdown-menu-right
|
||||
| {% if not current_user.has_role('protected') %}
|
||||
| {% block menu_list %}
|
||||
li
|
||||
a.navbar-item(
|
||||
href="{{ url_for('projects.home_project') }}"
|
||||
title="Home")
|
||||
| #[i.pi-home] Home
|
||||
|
||||
li
|
||||
a.navbar-item(
|
||||
href="{{ url_for('projects.index') }}"
|
||||
title="My Projects")
|
||||
| #[i.pi-star] My Projects
|
||||
|
||||
| {% if current_user.has_organizations() %}
|
||||
li
|
||||
a.navbar-item(
|
||||
href="{{ url_for('pillar.web.organizations.index') }}"
|
||||
title="My Organizations")
|
||||
| #[i.pi-users] My Organizations
|
||||
| {% endif %}
|
||||
|
||||
li
|
||||
a.navbar-item(
|
||||
href="{{ url_for('settings.profile') }}"
|
||||
title="Settings")
|
||||
a.navbar-item.px-2(
|
||||
href="{{ url_for('settings.profile') }}"
|
||||
title="Settings")
|
||||
| #[i.pi-cog] Settings
|
||||
|
||||
| {% endblock menu_list %}
|
||||
@@ -45,11 +26,11 @@ li.dropdown
|
||||
|
||||
li
|
||||
a.navbar-item(
|
||||
href="{{ url_for('users.logout') }}")
|
||||
href="{{ url_for('users.logout') }}")
|
||||
i.pi-log-out(title="Log Out")
|
||||
| Log out
|
||||
a.navbar-item.subitem(
|
||||
href="{{ url_for('users.switch') }}")
|
||||
a.navbar-item.subitem.pt-0(
|
||||
href="{{ url_for('users.switch') }}")
|
||||
i.pi-blank
|
||||
| Not {{ current_user.full_name }}?
|
||||
|
||||
|
73
src/templates/mixins/components.pug
Normal file
73
src/templates/mixins/components.pug
Normal file
@@ -0,0 +1,73 @@
|
||||
// {#
|
||||
// Header of landing pages. title or text can be skipped:
|
||||
// +jumbotron("{{ page_title }}", null, "{{ page_header_image }}")
|
||||
// Any extra attributes added (in a separate group) will be passed as is:
|
||||
// +jumbotron("{{ page_title }}", null, "{{ page_header_image }}")(data-node-id='{{ node._id }}')
|
||||
// #}
|
||||
mixin jumbotron(title, text, image, url)
|
||||
if url
|
||||
a.jumbotron.text-white(
|
||||
style='background-image: url(' + image + ');',
|
||||
href=url)&attributes(attributes)
|
||||
.container
|
||||
.row
|
||||
.col-md-9
|
||||
if title
|
||||
.display-4.text-uppercase.font-weight-bold
|
||||
=title
|
||||
if text
|
||||
.lead
|
||||
=text
|
||||
else
|
||||
.jumbotron.text-white(style='background-image: url(' + image + ');')&attributes(attributes)
|
||||
.container
|
||||
.row
|
||||
.col-md-9
|
||||
if title
|
||||
.display-4.text-uppercase.font-weight-bold
|
||||
=title
|
||||
if text
|
||||
.lead
|
||||
=text
|
||||
|
||||
// {# Secondary navigation.
|
||||
// e.g. Workshops, Courses. #}
|
||||
mixin nav-secondary(title)
|
||||
ul.nav.nav-secondary&attributes(attributes)
|
||||
if title
|
||||
li.nav-item
|
||||
span.nav-title.nav-link.font-weight-bold.pointer-events-none= title
|
||||
|
||||
if block
|
||||
block
|
||||
else
|
||||
p No items defined.
|
||||
|
||||
mixin nav-secondary-link()
|
||||
li.nav-item
|
||||
a.nav-link&attributes(attributes)
|
||||
block
|
||||
|
||||
mixin card-deck(max_columns)
|
||||
.card-deck.card-padless.card-deck-responsive(class="card-" + max_columns + "-columns")&attributes(attributes)
|
||||
if block
|
||||
block
|
||||
else
|
||||
.p-3 No items.
|
||||
|
||||
// {#
|
||||
// Passes all attributes to the card.
|
||||
// You can do fun stuff in a loop even like:
|
||||
// +card(data-url="{{ url_for('projects.view', project_url=project.url) }}", tabindex='{{ loop.index }}')
|
||||
// #}
|
||||
mixin card()
|
||||
.card&attributes(attributes)
|
||||
if block
|
||||
block
|
||||
else
|
||||
p No card content defined.
|
||||
|
||||
mixin list-asset(name, url, image, type, date)
|
||||
if block
|
||||
block
|
||||
|
@@ -33,8 +33,8 @@ script(type="text/javascript").
|
||||
} else if (node_type === 'group_hdri') {
|
||||
node_type_str = 'HDRi Folder';
|
||||
}
|
||||
$('a', '.button-edit').html('<i class="pi-edit button-edit-icon"></i> Edit ' + node_type_str);
|
||||
$('a', '.button-delete').html('<i class="pi-trash button-delete-icon"></i>Delete ' + node_type_str);
|
||||
|
||||
$('a', '.button-delete').html('<i class="pr-2 pi-trash button-delete-icon"></i>Delete ' + node_type_str);
|
||||
|
||||
{% if parent %}
|
||||
ProjectUtils.setProjectAttributes({parentNodeId: '{{parent._id}}'});
|
||||
@@ -114,7 +114,6 @@ script(type="text/javascript").
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$(page_overlay).find('.nav-prev').click(function(e){
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
@@ -133,10 +132,6 @@ script(type="text/javascript").
|
||||
$(this).removeClass('active').hide().html();
|
||||
});
|
||||
|
||||
if (typeof $().popover != 'undefined'){
|
||||
$('#asset-license').popover();
|
||||
}
|
||||
|
||||
{% endif %}
|
||||
|
||||
var $content_type = $(".js-type");
|
||||
|
@@ -23,7 +23,7 @@ section.node-preview.video
|
||||
|
||||
| {% block node_download %}
|
||||
| {% if node.file_variations %}
|
||||
button.btn.btn-outline-secondary.dropdown-toggle(
|
||||
button.btn.btn-sm.btn-outline-primary.dropdown-toggle.px-3(
|
||||
type="button",
|
||||
data-toggle="dropdown",
|
||||
aria-haspopup="true",
|
||||
@@ -32,7 +32,7 @@ button.btn.btn-outline-secondary.dropdown-toggle(
|
||||
| Download
|
||||
i.pi-angle-down.icon-dropdown-menu
|
||||
|
||||
ul.dropdown-menu
|
||||
ul.dropdown-menu.dropdown-menu-right
|
||||
| {% for variation in node.file_variations %}
|
||||
li
|
||||
a(href="{{ variation.link }}",
|
||||
@@ -52,25 +52,49 @@ script(type="text/javascript").
|
||||
{% if node.video_sources %}
|
||||
|
||||
var videoPlayer = document.getElementById('videoplayer');
|
||||
|
||||
var options = {
|
||||
controlBar: {
|
||||
volumePanel: { inline: false }
|
||||
}
|
||||
},
|
||||
playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 4]
|
||||
};
|
||||
|
||||
videojs.registerPlugin('analytics', function() {
|
||||
videojs(videoPlayer, options).ready(function() {
|
||||
this.ga({
|
||||
'eventLabel' : '{{ node._id }} - {{ node.name }}',
|
||||
'eventCategory' : '{{ node.project }}',
|
||||
'eventsToTrack' : ['start', 'error', 'percentsPlayed']
|
||||
});
|
||||
|
||||
this.hotkeys({
|
||||
enableVolumeScroll: false,
|
||||
customKeys: {
|
||||
KeyL: {
|
||||
key: function(event) {
|
||||
return (event.which === 76);
|
||||
},
|
||||
handler: function(player, options, event) {
|
||||
videoPlayerToggleLoop(videoPlayer, videoPlayerLoopButton);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.rememberVolumePlugin();
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
let fetch_progress_url = '{{ url_for("users_api.get_video_progress", video_id=node._id) }}';
|
||||
let report_url = '{{ url_for("users_api.set_video_progress", video_id=node._id) }}';
|
||||
|
||||
this.progressPlugin({
|
||||
'report_url': report_url,
|
||||
'fetch_progress_url': fetch_progress_url,
|
||||
});
|
||||
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
videojs(videoPlayer, options).ready(function() {
|
||||
this.hotkeys();
|
||||
});
|
||||
|
||||
// Generic utility to add-buttons to the player.
|
||||
function addVideoPlayerButton(data) {
|
||||
|
||||
var controlBar,
|
||||
@@ -89,6 +113,7 @@ script(type="text/javascript").
|
||||
return newButton;
|
||||
}
|
||||
|
||||
// Video loop stuff. TODO: Move it to video_plugins.js
|
||||
var videoPlayerLoopButton = addVideoPlayerButton({
|
||||
player: videoPlayer,
|
||||
class: 'vjs-loop-button',
|
||||
@@ -96,15 +121,18 @@ script(type="text/javascript").
|
||||
title: 'Loop'
|
||||
});
|
||||
|
||||
videoPlayerLoopButton.onclick = function() {
|
||||
function videoPlayerToggleLoop(videoPlayer, videoPlayerLoopButton) {
|
||||
if (videoPlayer.loop){
|
||||
videoPlayer.loop = false;
|
||||
$(this).removeClass('vjs-control-active');
|
||||
|
||||
$(videoPlayerLoopButton).removeClass('vjs-control-active');
|
||||
} else {
|
||||
videoPlayer.loop = true;
|
||||
$(this).addClass('vjs-control-active');
|
||||
$(videoPlayerLoopButton).addClass('vjs-control-active');
|
||||
}
|
||||
}
|
||||
|
||||
videoPlayerLoopButton.onclick = function() {
|
||||
videoPlayerToggleLoop(videoPlayer, videoPlayerLoopButton);
|
||||
};
|
||||
|
||||
{% endif %} // if node.video_sources
|
||||
|
@@ -1,8 +1,11 @@
|
||||
| {% extends 'nodes/custom/blog/index.html' %}
|
||||
| {% import 'nodes/custom/blog/_macros.html' as blogmacros %}
|
||||
|
||||
| {% block project_context %}
|
||||
#blog_container
|
||||
#blog_index-container.expand-image-links
|
||||
| {{ blogmacros.render_archive(project, posts, posts_meta) }}
|
||||
| {% endblock project_context%}
|
||||
| {% block body %}
|
||||
.container
|
||||
.pt-5.pb-2
|
||||
h2.text-uppercase.font-weight-bold.text-center
|
||||
| {{ project.name }} Blog Archive
|
||||
|
||||
| {{ blogmacros.render_archive(project, posts, posts_meta) }}
|
||||
| {% endblock body %}
|
||||
|
@@ -1,9 +0,0 @@
|
||||
| {% extends 'nodes/custom/blog/index_main_project.html' %}
|
||||
| {% import 'nodes/custom/blog/_macros.html' as blogmacros %}
|
||||
|
||||
| {% block body %}
|
||||
.container
|
||||
h3 Blog Archive
|
||||
|
||||
| {{ blogmacros.render_archive(project, posts, posts_meta) }}
|
||||
| {% endblock body %}
|
@@ -1,55 +1,40 @@
|
||||
| {% extends 'projects/view.html' %}
|
||||
| {% extends 'layout.html' %}
|
||||
| {% import 'nodes/custom/blog/_macros.html' as blogmacros %}
|
||||
| {% from 'projects/_macros.html' import render_secondary_navigation %}
|
||||
|
||||
| {% set title = 'blog' %}
|
||||
|
||||
| {% block page_title %}Blog{% endblock%}
|
||||
|
||||
| {% block css %}
|
||||
| {{ super() }}
|
||||
link(href="{{ url_for('static_pillar', filename='assets/css/blog.css') }}", rel="stylesheet")
|
||||
| {% endblock %}
|
||||
| {% block navigation_tabs %}
|
||||
| {{ render_secondary_navigation(project, navigation_links, title) }}
|
||||
| {% endblock navigation_tabs %}
|
||||
|
||||
| {% block project_context %}
|
||||
| {{ blogmacros.render_blog_index(project, posts, can_create_blog_posts, api, more_posts_available, posts_meta) }}
|
||||
| {% endblock %}
|
||||
|
||||
| {% block project_tree %}
|
||||
#project_tree.jstree.jstree-default.blog
|
||||
ul.jstree-container-ul.jstree-children
|
||||
li.jstree-node(data-node-type="page")
|
||||
a.jstree-anchor(
|
||||
href="{{ url_for('projects.view', project_url=project.url) }}")
|
||||
| Browse Project
|
||||
|
||||
li.jstree-node(data-node-type="page")
|
||||
a.jstree-anchor.jstree-clicked(
|
||||
href="{{ url_for('main.project_blog', project_url=project.url) }}") Blog
|
||||
|
||||
| {% for post in posts %}
|
||||
li.jstree-node
|
||||
a.jstree-anchor.tree-item.post(
|
||||
href="{{ node.url }}")
|
||||
.tree-item-thumbnail
|
||||
| {% if post.picture %}
|
||||
img(src="{{ post.picture.thumbnail('s', api=api) }}")
|
||||
| {% else %}
|
||||
i.pi-document-text
|
||||
| {% endif %}
|
||||
span.tree-item-title {{ post.name }}
|
||||
span.tree-item-info {{ post._created | pretty_date }}
|
||||
| {% endfor %}
|
||||
| {% block body %}
|
||||
| {{ blogmacros.render_blog_index(node, project, posts, can_create_blog_posts, api, more_posts_available, posts_meta, pages=pages) }}
|
||||
| {% endblock %}
|
||||
|
||||
| {% block footer_scripts %}
|
||||
|
||||
include ../_scripts
|
||||
script.
|
||||
/* UI Stuff */
|
||||
var project_container = document.getElementById('project-container');
|
||||
hopToTop(); // Display jump to top button
|
||||
|
||||
$(window).on("load resize",function(){
|
||||
containerResizeY($(window).height());
|
||||
/* Expand images when their link points to a jpg/png/gif */
|
||||
/* TODO: De-duplicate code from view post */
|
||||
var page_overlay = document.getElementById('page-overlay');
|
||||
$('.item-content a img').on('click', function(e){
|
||||
e.preventDefault();
|
||||
|
||||
if ($(window).width() > 480) {
|
||||
project_container.style.height = (window.innerHeight - project_container.offsetTop) + "px";
|
||||
var href = $(this).parent().attr('href');
|
||||
var src = $(this).attr('src');
|
||||
|
||||
if (href.match("jpg$") || href.match("png$") || href.match("gif$")) {
|
||||
$(page_overlay)
|
||||
.addClass('active')
|
||||
.html('<img src="' + src + '"/>');
|
||||
} else {
|
||||
window.location.href = href;
|
||||
}
|
||||
});
|
||||
|
||||
|
@@ -1,40 +0,0 @@
|
||||
| {% extends 'layout.html' %}
|
||||
| {% import 'nodes/custom/blog/_macros.html' as blogmacros %}
|
||||
| {% set title = 'blog' %}
|
||||
|
||||
| {% block page_title %}Blog{% endblock%}
|
||||
|
||||
| {% block css %}
|
||||
| {{ super() }}
|
||||
link(href="{{ url_for('static_cloud', filename='assets/css/project-landing.css') }}", rel="stylesheet")
|
||||
| {% endblock css %}
|
||||
|
||||
| {% block body %}
|
||||
| {{ blogmacros.render_blog_index(project, posts, can_create_blog_posts, api, more_posts_available, posts_meta, pages=pages) }}
|
||||
| {% endblock %}
|
||||
|
||||
| {% block footer_scripts %}
|
||||
|
||||
include ../_scripts
|
||||
script.
|
||||
hopToTop(); // Display jump to top button
|
||||
|
||||
/* Expand images when their link points to a jpg/png/gif */
|
||||
/* TODO: De-duplicate code from view post */
|
||||
var page_overlay = document.getElementById('page-overlay');
|
||||
$('.item-content a img').on('click', function(e){
|
||||
e.preventDefault();
|
||||
|
||||
var href = $(this).parent().attr('href');
|
||||
var src = $(this).attr('src');
|
||||
|
||||
if (href.match("jpg$") || href.match("png$") || href.match("gif$")) {
|
||||
$(page_overlay)
|
||||
.addClass('active')
|
||||
.html('<img src="' + src + '"/>');
|
||||
} else {
|
||||
window.location.href = href;
|
||||
}
|
||||
});
|
||||
|
||||
| {% endblock %}
|
@@ -5,11 +5,14 @@
|
||||
class="{% if is_reply %}is-reply{% else %}is-first{% endif %}")
|
||||
|
||||
.comment-avatar
|
||||
img(src="{{ comment._user.email | gravatar }}")
|
||||
img(src="{{ comment._user.email | gravatar }}", alt="{{ comment._user.full_name }}")
|
||||
|
||||
.comment-content
|
||||
.comment-body
|
||||
p.comment-author {{ comment._user.full_name }}
|
||||
//- TODO(Pablo): due to the broad styling done on the .comment-content class the
|
||||
//- styling for the badges that I put in _project.sass isn't applied properly here.
|
||||
| {{ comment._user.badges_html|safe }}
|
||||
|
||||
span {{comment.properties | markdowned('content') }}
|
||||
|
||||
|
@@ -1,127 +1,41 @@
|
||||
| {% from '_macros/_asset_list_item.html' import asset_list_item %}
|
||||
include ../../../mixins/components
|
||||
|
||||
| {% block body %}
|
||||
#node-container
|
||||
|
||||
section.node-details-container
|
||||
.node-details-header
|
||||
.node-title#node-title
|
||||
| {{node.name}}
|
||||
|
||||
.node-details-meta-actions
|
||||
.btn-browsetoggle(
|
||||
title="Toggle between list/grid view",
|
||||
data-toggle="tooltip",
|
||||
data-placement="top")
|
||||
i.pi-list
|
||||
section.d-flex
|
||||
h4.p-4 {{node.name}}
|
||||
|
||||
| {% if node.description %}
|
||||
.node-details-description
|
||||
section.node-details-description.px-4
|
||||
| {{ node | markdowned('description') }}
|
||||
| {% endif %}
|
||||
|
||||
|
||||
section.node-children.group
|
||||
|
||||
section.container-fluid
|
||||
| {% if children %}
|
||||
| {% for child in children %}
|
||||
|
||||
| {# Browse type: List #}
|
||||
a(
|
||||
href="{{ url_for_node(node=child) }}",
|
||||
data-node_id="{{ child._id }}",
|
||||
class="item_icon list-node-children-item browse-list")
|
||||
.list-node-children-item-thumbnail
|
||||
.d-flex.justify-content-end.mb-2
|
||||
button.btn.btn-sm.btn-outline-secondary(
|
||||
class="js-btn-browsetoggle",
|
||||
title="Toggle between list/grid view",
|
||||
data-toggle="tooltip",
|
||||
data-placement="top")
|
||||
i.pi-list
|
||||
|
||||
| {% if child.picture %}
|
||||
img(
|
||||
src="{{ child.picture.thumbnail('t', api=api)}} ")
|
||||
| {% else %}
|
||||
.cloud-logo
|
||||
i.pi-blender-cloud
|
||||
| {% endif %}
|
||||
|
||||
| {% if child.permissions.world %}
|
||||
.list-node-children-item-ribbon
|
||||
span free
|
||||
| {% endif %}
|
||||
|
||||
.list-node-children-item-thumbnail-icon
|
||||
| {% if child.properties.content_type and child.properties.content_type == 'video' %}
|
||||
i.pi-play
|
||||
| {% elif child.properties.content_type and child.properties.content_type == 'image' %}
|
||||
i.pi-image
|
||||
| {% elif child.properties.content_type and child.properties.content_type == 'file' %}
|
||||
i.pi-file-archive
|
||||
| {% else %}
|
||||
i.pi-folder
|
||||
| {% endif %}
|
||||
|
||||
.list-node-children-item-name {{ child.name }}
|
||||
|
||||
.list-node-children-item-meta
|
||||
| {% if child.properties.status != 'published' %}
|
||||
span.status {{ child.properties.status }}
|
||||
| {% endif %}
|
||||
|
||||
span.type
|
||||
| {% if child.properties.content_type %}
|
||||
| {{ child.properties.content_type | undertitle }} ·
|
||||
| {% elif child.node_type == 'group' %}
|
||||
| Folder ·
|
||||
| {% else %}
|
||||
| {{ child.node_type | undertitle }} ·
|
||||
| {% endif %}
|
||||
|
||||
span(title="Created on {{ child._created }}") {{ child._created | pretty_date }}
|
||||
|
||||
| {# Browse type: Icon #}
|
||||
a(href="{{ url_for_node(node=child) }}",
|
||||
data-node_id="{{ child._id }}",
|
||||
title="{{ child.name }}",
|
||||
class="item_icon list-node-children-item browse-icon {% if child.picture %}has-picture{% endif %}")
|
||||
.list-node-children-item-thumbnail
|
||||
|
||||
| {% if child.picture %}
|
||||
img(
|
||||
src="{{ child.picture.thumbnail('m', api=api)}} ")
|
||||
| {% else %}
|
||||
.cloud-logo
|
||||
i.pi-blender-cloud
|
||||
| {% endif %}
|
||||
|
||||
.list-node-children-item-thumbnail-icon
|
||||
| {% if child.properties.content_type and child.properties.content_type == 'video' %}
|
||||
i.pi-play
|
||||
| {% elif child.properties.content_type and child.properties.content_type == 'image' %}
|
||||
i.pi-image
|
||||
| {% elif child.properties.content_type and child.properties.content_type == 'file' %}
|
||||
i.pi-file-archive
|
||||
| {% else %}
|
||||
i.pi-folder
|
||||
| {% endif %}
|
||||
|
||||
| {% if child.properties.status != 'published' %}
|
||||
.list-node-children-item-status {{ child.properties.status }}
|
||||
| {% endif %}
|
||||
|
||||
| {% if child.permissions.world %}
|
||||
.list-node-children-item-ribbon
|
||||
span free
|
||||
| {% endif %}
|
||||
|
||||
.list-node-children-item-name
|
||||
span {{ child.name }}
|
||||
|
||||
| {% endfor %}
|
||||
| {% else %}
|
||||
.list-node-children-container
|
||||
.list-node-children-empty No items... yet!
|
||||
+card-deck(class="px-2")
|
||||
| {% for child in children %}
|
||||
| {{ asset_list_item(child, current_user) }}
|
||||
| {% endfor %}
|
||||
| {% else %}
|
||||
.list-node-children-container
|
||||
.list-node-children-empty No items... yet!
|
||||
| {% endif %}
|
||||
|
||||
script.
|
||||
// Generate GA pageview
|
||||
ga('send', 'pageview', location.pathname);
|
||||
|
||||
$('a.item_icon').unbind("click")
|
||||
$('a.js-item-open').unbind("click")
|
||||
.click(function(e){
|
||||
e.preventDefault();
|
||||
|
||||
@@ -149,15 +63,13 @@
|
||||
|
||||
// Browse type: icon or list
|
||||
function projectBrowseTypeIcon() {
|
||||
$(".list-node-children-item.browse-list").hide();
|
||||
$(".list-node-children-item.browse-icon").show();
|
||||
$(".btn-browsetoggle").html('<i class="pi-list"></i>');
|
||||
$(".card-deck").removeClass('card-deck-vertical');
|
||||
$(".js-btn-browsetoggle").html('<i class="pi-list"></i> List View');
|
||||
};
|
||||
|
||||
function projectBrowseTypeList() {
|
||||
$(".list-node-children-item.browse-list").show();
|
||||
$(".list-node-children-item.browse-icon").hide();
|
||||
$(".btn-browsetoggle").html('<i class="pi-layout"></i>');
|
||||
$(".card-deck").addClass('card-deck-vertical');
|
||||
$(".js-btn-browsetoggle").html('<i class="pi-layout"></i> Grid View');
|
||||
};
|
||||
|
||||
function projectBrowseTypeCheck(){
|
||||
@@ -197,7 +109,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
$('.btn-browsetoggle').on('click', function (e) {
|
||||
$('.js-btn-browsetoggle').on('click', function (e) {
|
||||
e.preventDefault();
|
||||
projectBrowseToggle();
|
||||
});
|
||||
|
@@ -1,29 +1,28 @@
|
||||
| {% extends 'projects/landing.html' %}
|
||||
include ../../../mixins/components
|
||||
|
||||
| {% block body %}
|
||||
| {% if node.picture %}
|
||||
header
|
||||
img.header(src="{{ node.picture.thumbnail('h', api=api) }}")
|
||||
| {% endif %}
|
||||
| {% block navbar_secondary %}
|
||||
| {{ super() }}
|
||||
| {% endblock navbar_secondary %}
|
||||
#node-container
|
||||
#node-overlay
|
||||
.expand-image-links.imgs-fluid
|
||||
| {% if node.picture %}
|
||||
+jumbotron(
|
||||
"{{ node.name }}",
|
||||
"{{ node._created | pretty_date }}{% if node.user.full_name %} · {{ node.user.full_name }}{% endif %}",
|
||||
"{{ node.picture.thumbnail('h', api=api) }}",
|
||||
"{{ node.url }}")
|
||||
| {% endif %}
|
||||
|
||||
section.node-details-container.page
|
||||
.container.pb-5
|
||||
.row
|
||||
.col-8.mx-auto
|
||||
h2.pt-5.pb-3.text-center {{node.name}}
|
||||
|
||||
.node-details-header
|
||||
.node-title#node-title
|
||||
| {{node.name}}
|
||||
| {% if node.description %}
|
||||
.node-details-description
|
||||
| {{ node | markdowned('description') }}
|
||||
| {% endif %}
|
||||
|
||||
| {% if node.description %}
|
||||
.node-details-description#node-description
|
||||
| {{ node | markdowned('description') }}
|
||||
| {% endif %}
|
||||
|
||||
.node-details-meta.footer
|
||||
span.updated(title="created {{ node._created | pretty_date }}") updated {{ node._updated | pretty_date }}
|
||||
small.text-muted
|
||||
span(title="created {{ node._created | pretty_date }}") Updated {{ node._updated | pretty_date }}
|
||||
|
||||
include ../_scripts
|
||||
|
||||
|
@@ -122,41 +122,38 @@ script(type="text/javascript").
|
||||
.toLowerCase();
|
||||
};
|
||||
|
||||
var convert = new Markdown.getSanitizingConverter().makeHtml;
|
||||
|
||||
/* Build the markdown preview when typing in textarea */
|
||||
$(function() {
|
||||
var $contentField = $('.form-group.description textarea'),
|
||||
$contentPreview = $('<div class="node-edit-form-md-preview" />').insertAfter($contentField);
|
||||
|
||||
var $textarea = $('.form-group.content textarea'),
|
||||
$loader = $('<div class="md-preview-loading"><i class="pi-spin spin"></i></div>').insertAfter($textarea),
|
||||
$preview = $('<div class="node-edit-form-md-preview" />').insertAfter($loader);
|
||||
function parseDescriptionContent(content) {
|
||||
|
||||
$loader.hide();
|
||||
$.ajax({
|
||||
url: "{{ url_for('nodes.preview_markdown')}}",
|
||||
type: 'post',
|
||||
data: {content: content},
|
||||
headers: {"X-CSRFToken": csrf_token},
|
||||
headers: {},
|
||||
dataType: 'json'
|
||||
})
|
||||
.done(function (data) {
|
||||
$contentPreview.html(data.content);
|
||||
})
|
||||
.fail(function (err) {
|
||||
toastr.error(xhrErrorResponseMessage(err), 'Parsing failed');
|
||||
});
|
||||
}
|
||||
|
||||
// Delay function to not start converting heavy posts immediately
|
||||
var delay = (function(){
|
||||
var timer = 0;
|
||||
return function(callback, ms){
|
||||
clearTimeout (timer);
|
||||
timer = setTimeout(callback, ms);
|
||||
};
|
||||
})();
|
||||
var options = {
|
||||
callback: parseDescriptionContent,
|
||||
wait: 750,
|
||||
highlight: false,
|
||||
allowSubmit: false,
|
||||
captureLength: 2
|
||||
}
|
||||
|
||||
$textarea.keyup(function() {
|
||||
/* If there's an iframe (YouTube embed), delay markdown convert 1.5s */
|
||||
if (/iframe/i.test($textarea.val())) {
|
||||
$loader.show();
|
||||
|
||||
delay(function(){
|
||||
// Convert markdown
|
||||
$preview.html(convert($textarea.val()));
|
||||
$loader.hide();
|
||||
}, 1500 );
|
||||
} else {
|
||||
// Convert markdown
|
||||
$preview.html(convert($textarea.val()));
|
||||
};
|
||||
}).trigger('keyup');
|
||||
$contentField.typeWatch(options);
|
||||
});
|
||||
|
||||
$(function() {
|
||||
|
@@ -1,73 +0,0 @@
|
||||
| {% extends 'projects/view.html' %}
|
||||
| {% set title = 'blog' %}
|
||||
|
||||
| {% block og %}
|
||||
meta(property="og:title", content="{{ node.name }}")
|
||||
meta(property="og:url", content="{{ url_for('main.project_blog', project_url=project.url, url=node.properties.url, _external=True)}}")
|
||||
meta(property="og:type", content="website")
|
||||
| {% if node.picture %}
|
||||
meta(property="og:image", content="{{ node.picture.thumbnail('l', api=api) }}")
|
||||
| {% endif %}
|
||||
meta(property="og:description", content="Blender Cloud is a web based service developed by Blender Institute that allows people to access the training videos and all the data from the open projects.")
|
||||
|
||||
meta(name="twitter:title", content="{{ node.name }}")
|
||||
meta(name="twitter:description", content="Blender Cloud is a web based service developed by Blender Institute that allows people to access the training videos and all the data from the open projects.")
|
||||
| {% if node.picture %}
|
||||
meta(property="og:image", content="{{ node.picture.thumbnail('l', api=api) }}")
|
||||
| {% endif %}
|
||||
| {% endblock %}
|
||||
|
||||
| {% block page_title %}{{node.name}} - Blog{% endblock%}
|
||||
|
||||
| {% block css %}
|
||||
| {{ super() }}
|
||||
link(href="{{ url_for('static_pillar', filename='assets/css/blog.css') }}", rel="stylesheet")
|
||||
| {% endblock %}
|
||||
|
||||
| {% block project_context %}
|
||||
| {% include 'nodes/custom/post/view_embed.html' %}
|
||||
| {% endblock %}
|
||||
|
||||
| {% block project_tree %}
|
||||
#project_tree.jstree.jstree-default.blog
|
||||
ul.jstree-container-ul.jstree-children
|
||||
li.jstree-node(data-node-type="page")
|
||||
a.jstree-anchor(
|
||||
href="{{ url_for('projects.view', project_url=project.url) }}")
|
||||
| Browse Project
|
||||
|
||||
li.jstree-node(data-node-type="page")
|
||||
a.jstree-anchor(
|
||||
href="{{ url_for('main.project_blog', project_url=project.url) }}") Blog
|
||||
|
||||
| {% for post in posts %}
|
||||
li.jstree-node
|
||||
a.jstree-anchor.tree-item.post(
|
||||
href="{{ url_for_node(node=post) }}",
|
||||
class="{% if post._id == node._id %}jstree-clicked{% endif %}")
|
||||
.tree-item-thumbnail
|
||||
| {% if post.picture %}
|
||||
img(src="{{ post.picture.thumbnail('s', api=api) }}")
|
||||
| {% else %}
|
||||
i.pi-document-text
|
||||
| {% endif %}
|
||||
span.tree-item-title {{ post.name }}
|
||||
span.tree-item-info {{ post._created | pretty_date }}
|
||||
| {% endfor %}
|
||||
| {% endblock %}
|
||||
|
||||
| {% block footer_scripts %}
|
||||
script.
|
||||
ProjectUtils.setProjectAttributes({projectId: "{{project._id}}", isProject: false, nodeId: '{{node._id}}'});
|
||||
|
||||
/* UI Stuff */
|
||||
var project_container = document.getElementById('project-container');
|
||||
$(window).on("load resize",function(){
|
||||
containerResizeY($(window).height());
|
||||
|
||||
if ($(window).width() > 480) {
|
||||
project_container.style.height = (window.innerHeight - project_container.offsetTop) + "px";
|
||||
}
|
||||
});
|
||||
|
||||
| {% endblock footer_scripts %}
|
@@ -1,9 +0,0 @@
|
||||
| {% import 'nodes/custom/blog/_macros.html' as blogmacros %}
|
||||
|
||||
| {{ blogmacros.render_blog_post(node, project=project) }}
|
||||
|
||||
#comments-embed.comments-compact
|
||||
.comments-list-loading
|
||||
i.pi-spin
|
||||
|
||||
include ../_scripts
|
@@ -18,11 +18,6 @@ meta(property="og:image", content="{{ node.picture.thumbnail('l', api=api) }}")
|
||||
|
||||
| {% block page_title %}{{node.name}} - Blog{% endblock%}
|
||||
|
||||
| {% block css %}
|
||||
| {{ super() }}
|
||||
link(href="{{ url_for('static_cloud', filename='assets/css/project-landing.css') }}", rel="stylesheet")
|
||||
| {% endblock css %}
|
||||
|
||||
| {% set title = 'blog' %}
|
||||
|
||||
| {% block body %}
|
||||
|
@@ -113,7 +113,6 @@ include ../_scripts
|
||||
|
||||
| {% block footer_scripts %}
|
||||
script.
|
||||
$('#asset-license').popover();
|
||||
// Generate GA pageview
|
||||
ga('send', 'pageview', location.pathname);
|
||||
|
||||
|
@@ -71,7 +71,7 @@
|
||||
|
||||
hr
|
||||
|
||||
ul.project-edit-tools.justify-content-end.h-auto
|
||||
ul.project-edit-tools.disabled.d-flex.list-unstyled.p-2.mb-0.h-auto.justify-content-end
|
||||
li.button-cancel
|
||||
a#item_cancel.item-cancel.project-mode-edit.btn.btn-outline-secondary(
|
||||
href="javascript:void(0);",
|
||||
|
@@ -10,7 +10,7 @@
|
||||
| {{node.name}}
|
||||
|
||||
| {% if node.description %}
|
||||
.node-details-description#node-description
|
||||
.node-details-description
|
||||
| {{node.description}}
|
||||
| {% endif %}
|
||||
|
||||
|
@@ -1,4 +1,7 @@
|
||||
| {% extends 'layout.html' %}
|
||||
| {% from '_macros/_asset_list_item.html' import asset_list_item %}
|
||||
include ../mixins/components
|
||||
|
||||
| {% block page_title %}Search{% if project %} {{ project.name }}{% endif %}{% endblock %}
|
||||
|
||||
| {% block head %}
|
||||
@@ -28,86 +31,59 @@ script.
|
||||
document.body.dataset["projectId"] = "{{project._id}}";
|
||||
| {% endif %}
|
||||
|
||||
#search-container
|
||||
| {% if project %}
|
||||
#project_sidebar.bg-white
|
||||
ul.project-tabs.p-0
|
||||
li.tabs-browse(
|
||||
title="Browse",
|
||||
data-toggle="tooltip",
|
||||
data-placement="right")
|
||||
a(href="{{url_for('projects.view', project_url=project.url, _external=True)}}")
|
||||
i.pi-folder
|
||||
|
||||
| {% if project %}
|
||||
#project_sidebar
|
||||
ul.project-tabs
|
||||
li.tabs-thumbnail(
|
||||
title="About",
|
||||
data-toggle="tooltip",
|
||||
data-placement="left",
|
||||
class="{% if title == 'about' %}active {% endif %}{% if project.picture_square %}image{% endif %}")
|
||||
a(href="{{url_for('projects.view', project_url=project.url, _external=True)}}")
|
||||
#project-loading
|
||||
i.pi-spin
|
||||
| {% if project.picture_square %}
|
||||
img(src="{{ project.picture_square.thumbnail('b', api=api) }}")
|
||||
| {% else %}
|
||||
i.pi-home
|
||||
| {% endif %}
|
||||
li.tabs-browse(
|
||||
title="Browse",
|
||||
data-toggle="tooltip",
|
||||
data-placement="left")
|
||||
a(href="{{url_for('projects.view', project_url=project.url, _external=True)}}")
|
||||
i.pi-folder
|
||||
li.tabs-search.active(
|
||||
title="Search",
|
||||
data-toggle="tooltip",
|
||||
data-placement="left")
|
||||
a(href="{{url_for('projects.search', project_url=project.url, _external=True)}}")
|
||||
i.pi-search
|
||||
| {% endif %}
|
||||
li.tabs-search.active(
|
||||
title="Search",
|
||||
data-toggle="tooltip",
|
||||
data-placement="right")
|
||||
a(href="{{url_for('projects.search', project_url=project.url, _external=True)}}")
|
||||
i.pi-search
|
||||
| {% endif %}
|
||||
|
||||
#search-sidebar
|
||||
input.search-field(
|
||||
type="text",
|
||||
name="q",
|
||||
id="q",
|
||||
autocomplete="off",
|
||||
spellcheck="false",
|
||||
autocorrect="false",
|
||||
placeholder="Search by Title, Type...")
|
||||
|
||||
.search-list-filters
|
||||
.filter-list
|
||||
| View as:
|
||||
ul.filter-list
|
||||
li.filter-list-type.grid(
|
||||
title="Browse as grid",
|
||||
data-list-type="grid")
|
||||
i.pi-layout
|
||||
li.filter-list-type.list(
|
||||
title="Browse as list",
|
||||
data-list-type="list")
|
||||
i.pi-list
|
||||
#search-container.d-flex(class="{% if project %}search-project{% endif %}")
|
||||
.search-list
|
||||
input.search-field.p-2.bg-white(
|
||||
type="text",
|
||||
name="q",
|
||||
id="q",
|
||||
autocomplete="off",
|
||||
spellcheck="false",
|
||||
autocorrect="false",
|
||||
placeholder="Search by Title, Type...")
|
||||
|
||||
#accordion.panel-group.accordion(role="tablist", aria-multiselectable="true")
|
||||
#facets
|
||||
#pagination.mt-3
|
||||
|
||||
#pagination
|
||||
//- #accordion.panel-group.accordion(role="tablist", aria-multiselectable="true")
|
||||
#facets
|
||||
|
||||
.search-list-stats
|
||||
#stats
|
||||
#stats.search-list-stats
|
||||
|
||||
#search-list
|
||||
#hits
|
||||
+card-deck()(id='hits', class="h-100 m-0 pt-3 pr-2 card-deck-vertical")
|
||||
|
||||
#search-details
|
||||
#search-details.border-left.search-details
|
||||
#search-error
|
||||
#search-hit-container
|
||||
#search-hit-container.w-100
|
||||
|
||||
|
||||
| {% raw %}
|
||||
// Facet template
|
||||
script(type="text/template", id="facet-template")
|
||||
.panel.panel-default
|
||||
.card
|
||||
a(data-toggle='collapse', data-parent='#accordion', href='#filter_{{ facet }}', aria-expanded='true', aria-controls='filter_{{ facet }}')
|
||||
.panel-heading(role='tab')
|
||||
.panel-title {{ title }}
|
||||
.panel-collapse.collapse.in(id='filter_{{ facet }}', role='tabpanel', aria-labelledby='headingOne')
|
||||
.panel-body
|
||||
.card-header(role='tab')
|
||||
.card-title {{ title }}
|
||||
.collapse.show(id='filter_{{ facet }}', role='tabpanel', aria-labelledby='headingOne')
|
||||
.card-body
|
||||
| {{#values}}
|
||||
a.facet_link.toggleRefine(
|
||||
class='{{#refined}}refined{{/refined}}',
|
||||
@@ -122,38 +98,33 @@ script(type="text/template", id="facet-template")
|
||||
|
||||
// Hit template
|
||||
script(type="text/template", id="hit-template")
|
||||
.search-hit(data-hit-id='{{ objectID }}')
|
||||
#search-loading.search-loading
|
||||
.spinner
|
||||
span.spin ·
|
||||
.search-hit-thumbnail
|
||||
a.card.asset.card-image-fade.pl-0.mx-0.mb-1(
|
||||
data-hit-id='{{ objectID }}',
|
||||
href="/nodes/{{ objectID }}/redir",
|
||||
class="js-search-hit {{#is_free}}free{{/is_free}}")
|
||||
.embed-responsive.embed-responsive-16by9
|
||||
| {{#picture}}
|
||||
img(src="{{{ picture }}}")
|
||||
.card-img-top.embed-responsive-item(style="background-image: url({{{ picture }}})")
|
||||
| {{/picture}}
|
||||
| {{^picture}}
|
||||
.search-hit-thumbnail-icon
|
||||
.card-img-top.card-icon.embed-responsive-item
|
||||
| {{#media}}
|
||||
i(class="pi-{{{ media }}}")
|
||||
| {{/media}}
|
||||
| {{^media}}
|
||||
i.dark(class="pi-{{{ node_type }}}")
|
||||
i(class="pi-{{{ node_type }}}")
|
||||
| {{/media}}
|
||||
| {{/picture}}
|
||||
| {{#is_free}}
|
||||
.search-hit-ribbon
|
||||
span free
|
||||
| {{/is_free}}
|
||||
.search-hit-name
|
||||
| {{ name }}
|
||||
.search-hit-meta
|
||||
span.project {{ project.name }}
|
||||
span.node_type {{{ node_type }}}
|
||||
| {{#media}}
|
||||
span.media · {{{ media }}}
|
||||
| {{/media}}
|
||||
span.when {{{ created_at }}}
|
||||
span.context
|
||||
a(href="/nodes/{{ objectID }}/redir") view in context
|
||||
.card-body.py-2.d-flex.flex-column
|
||||
.card-title.mb-1.font-weight-bold
|
||||
| {{ name }}
|
||||
|
||||
ul.card-text.list-unstyled.d-flex.text-black-50.mt-auto
|
||||
li.pr-2.project {{ project.name }}
|
||||
| {{#media}}
|
||||
li.pr-2.text-capitalize {{{ media }}}
|
||||
| {{/media}}
|
||||
li.pr-2 {{{ created_at }}}
|
||||
|
||||
|
||||
// Pagination template
|
||||
@@ -174,7 +145,6 @@ script(type="text/template", id="stats-template")
|
||||
| {% endblock %}
|
||||
|
||||
| {% block footer_scripts %}
|
||||
script(src="//releases.flowplayer.org/6.0.5/flowplayer.min.js", async)
|
||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/hogan.common-3.0.0.js') }}")
|
||||
script(src="{{ url_for('static_pillar', filename='assets/js/elasticsearch.min.js') }}")
|
||||
|
||||
@@ -186,31 +156,30 @@ script.
|
||||
$('#search-hit-container').html(dataHtml);
|
||||
})
|
||||
.done(function(){
|
||||
$('.search-loading').removeClass('active');
|
||||
$('.loader-bar').removeClass('active');
|
||||
$('#search-error').hide();
|
||||
$('#search-hit-container').show();
|
||||
})
|
||||
.fail(function(data){
|
||||
$('.search-loading').removeClass('active');
|
||||
$('.loader-bar').removeClass('active');
|
||||
$('#search-hit-container').hide();
|
||||
$('#search-error').show().html('Houston!\n\n' + data.status + ' ' + data.statusText);
|
||||
});
|
||||
}
|
||||
|
||||
$('body').on('click', '.search-hit', function(){
|
||||
if ($('.search-loading').hasClass('active')){
|
||||
$(this).removeClass('active');
|
||||
}
|
||||
$(this).find('#search-loading').addClass('active');
|
||||
$('body').on('click', '.js-search-hit', function(e){
|
||||
e.preventDefault();
|
||||
|
||||
$('.loader-bar').removeClass('active').addClass('active');
|
||||
|
||||
displayNode($(this).data('hit-id'));
|
||||
$('.search-hit').removeClass('active');
|
||||
$('.js-search-hit').removeClass('active');
|
||||
$(this).addClass('active');
|
||||
});
|
||||
|
||||
// Remove focus from search input so that the click event bound to .search-hit
|
||||
// can be fired on the first click.
|
||||
$(searchList).hover(function(){
|
||||
// Remove focus from search input so that the click event
|
||||
// bound to .search-hit can be fired on the first click.
|
||||
$('#search-list').hover(function(){
|
||||
$('#q').blur();
|
||||
});
|
||||
$('#search-sidebar').hover(function(){
|
||||
@@ -218,42 +187,6 @@ script.
|
||||
});
|
||||
|
||||
/* UI Stuff */
|
||||
|
||||
/* List types, grid or list (default)*/
|
||||
var uiListType = Cookies.getJSON('bcloud_ui');
|
||||
var searchList = document.getElementById('search-list');
|
||||
|
||||
function uiSetListType(type){
|
||||
$('.filter-list-type').removeClass('active');
|
||||
|
||||
if (type == 'grid'){
|
||||
$(searchList).addClass('view-grid');
|
||||
$('.filter-list-type.grid').addClass('active');
|
||||
} else {
|
||||
$(searchList).removeClass('view-grid');
|
||||
$('.filter-list-type.list').addClass('active');
|
||||
}
|
||||
}
|
||||
|
||||
if (uiListType && uiListType.search_browse_type == 'grid'){
|
||||
uiSetListType('grid');
|
||||
} else {
|
||||
uiSetListType('list');
|
||||
}
|
||||
|
||||
$('.filter-list-type').on('click', function(){
|
||||
if ($(this).attr('data-list-type') == 'grid'){
|
||||
uiSetListType('grid');
|
||||
setJSONCookie('bcloud_ui', 'search_browse_type', 'grid');
|
||||
} else {
|
||||
uiSetListType('list');
|
||||
setJSONCookie('bcloud_ui', 'search_browse_type', 'list');
|
||||
}
|
||||
});
|
||||
|
||||
/* Hide site-wide search, kinda confusing */
|
||||
$('.search-input').hide();
|
||||
|
||||
/* Resize container so we can have custom scrollbars */
|
||||
container_offset = $('#search-container').offset();
|
||||
|
||||
|
@@ -5,9 +5,8 @@
|
||||
| {% block node_preview %}
|
||||
| {% if node.picture %}
|
||||
| {% if current_user.has_cap('subscriber') or node.permissions.world %}
|
||||
section#node-preview.node-preview.image.js-node-preview-image
|
||||
img.node-preview-thumbnail#node-preview-thumbnail(
|
||||
src="{{ node.picture.thumbnail('l', api=api) }}")
|
||||
section.node-preview.image.js-node-preview-image
|
||||
img.node-preview-thumbnail(src="{{ node.picture.thumbnail('l', api=api) }}")
|
||||
| {% else %}
|
||||
| {% include 'nodes/custom/_node_preview_forbidden.html' %}
|
||||
| {% endif %}
|
||||
@@ -15,117 +14,131 @@
|
||||
| {% endblock node_preview %}
|
||||
|
||||
| {% block node_details %}
|
||||
section.node-details-container
|
||||
|
||||
| {# NAME #}
|
||||
.node-details-header
|
||||
.node-title#node-title
|
||||
| {{node.name}}
|
||||
|
||||
| {# NAME #}
|
||||
section.px-4
|
||||
h4.pt-4.mb-3 {{node.name}}
|
||||
|
||||
| {# DESCRIPTION #}
|
||||
| {% if node.description %}
|
||||
.node-details-description#node-description
|
||||
.node-details-description
|
||||
| {{ node | markdowned('description') }}
|
||||
| {% endif %}
|
||||
|
||||
|
||||
| {# LICENSE #}
|
||||
| {% if node.properties.license_type %}
|
||||
a.node-details-license(
|
||||
href="https://creativecommons.org/licenses/",
|
||||
target="_blank")
|
||||
span.type
|
||||
i(class="pi-license-{{ node.properties.license_type }}")
|
||||
| License <span>{{ node.properties.license_type }}</span>
|
||||
| {% if node.properties.license_notes %}
|
||||
| — {{ node.properties.license_notes }}
|
||||
| {% endif %}
|
||||
| {% endif %}
|
||||
|
||||
|
||||
| {# DETAILS #}
|
||||
.node-details-meta
|
||||
ul
|
||||
| {% if node.has_method('PUT') and (node.properties.status != 'published') %}
|
||||
li(class="status-{{ node.properties.status }}")
|
||||
| {{ node.properties.status | undertitle }}
|
||||
| {% endif %}
|
||||
|
||||
li(title="Author")
|
||||
| {{ node.user.full_name }}
|
||||
|
||||
li(
|
||||
title="Created {{ node._created }} (updated {{ node._updated | pretty_date_time }})")
|
||||
| {{ node._created | pretty_date }}
|
||||
|
||||
| {% if node.short_link %}
|
||||
li.shared
|
||||
a(href="{{ node.short_link }}")
|
||||
i.pi-share
|
||||
| Shared
|
||||
| {% endif %}
|
||||
|
||||
li.left-side
|
||||
|
||||
| {% if node.file %}
|
||||
li(title="File size")
|
||||
| {{ node.file.length | filesizeformat }}
|
||||
li.js-type(title="File format")
|
||||
| {{ node.file.content_type }}
|
||||
| {% endif %}
|
||||
|
||||
| {% if node.permissions.world %}
|
||||
li.public(
|
||||
| {# DETAILS #}
|
||||
section.node-details-meta.pl-4.pr-2.py-2.border-bottom
|
||||
ul.list-unstyled.m-0
|
||||
| {% if node.properties.license_type %}
|
||||
li.px-2
|
||||
a.node-details-license(
|
||||
href="https://creativecommons.org/licenses/",
|
||||
target="_blank",
|
||||
title="{{ node.properties.license_type }} {% if node.properties.license_notes %}{{ node.properties.license_notes }}{% endif %}",
|
||||
data-toggle="tooltip",
|
||||
data-placement="bottom",
|
||||
title="Anybody can download. Share it!")
|
||||
i.pi-lock-open
|
||||
span Public
|
||||
data-placement="top")
|
||||
i(class="pi-license-{{ node.properties.license_type }}")
|
||||
| {% endif %}
|
||||
|
||||
| {% if node.has_method('PUT') and (node.properties.status != 'published') %}
|
||||
li.px-2(class="status-{{ node.properties.status }}")
|
||||
| {{ node.properties.status | undertitle }}
|
||||
| {% endif %}
|
||||
|
||||
li.px-2(title="Author")
|
||||
| {{ node.user.full_name }}
|
||||
| {{ node.user.badges.html|safe }}
|
||||
|
||||
li.px-2(
|
||||
title="Created {{ node._created }} (updated {{ node._updated | pretty_date_time }})")
|
||||
| {{ node._created | pretty_date }}
|
||||
|
||||
| {% if node.short_link %}
|
||||
li.shared
|
||||
a(href="{{ node.short_link }}")
|
||||
i.pi-share
|
||||
| Shared
|
||||
| {% endif %}
|
||||
|
||||
li.ml-auto
|
||||
|
||||
| {% if node.file %}
|
||||
li.px-2(title="File size")
|
||||
| {{ node.file.length | filesizeformat }}
|
||||
li.px-2.js-type(title="File format")
|
||||
| {{ node.file.content_type }}
|
||||
| {% endif %}
|
||||
|
||||
| {% if node.permissions.world %}
|
||||
li.public(
|
||||
data-toggle="tooltip",
|
||||
data-placement="bottom",
|
||||
title="Anybody can download. Share it!")
|
||||
i.pi-lock-open
|
||||
span Public
|
||||
| {% endif %}
|
||||
|
||||
| {% block node_details_meta_extra %}{% endblock %}
|
||||
|
||||
li.download
|
||||
| {% if (current_user.has_cap('subscriber') or node.permissions.world) and (node.file or node.properties.files) %}
|
||||
| {% block node_download %}
|
||||
a(
|
||||
title="Download {{ node.properties.content_type | undertitle }}",
|
||||
href="{{ node.file.link }}",
|
||||
download="{{ node.file.filename }}")
|
||||
button.btn.btn-sm.btn-outline-primary.px-3(type="button")
|
||||
i.pi-download.pr-2
|
||||
| Download
|
||||
| {% endblock node_download %}
|
||||
|
||||
| {% elif current_user.has_cap('can-renew-subscription') %}
|
||||
a.btn.btn-outline-primary(
|
||||
title="Renew your subscription to download",
|
||||
target="_blank",
|
||||
href="/renew")
|
||||
i.pi-heart.pr-2
|
||||
| Renew Subscription
|
||||
|
||||
| {% elif current_user.is_authenticated %}
|
||||
.btn.disabled
|
||||
i.pi-lock
|
||||
| Download
|
||||
|
||||
| {% else %}
|
||||
a.btn(
|
||||
title="Login to download {{ node.properties.content_type | undertitle }}",
|
||||
href="{{ url_for('users.login') }}")
|
||||
i.pi-lock
|
||||
| Download
|
||||
| {% endif %}
|
||||
|
||||
| {% block node_details_meta_extra %}{% endblock %}
|
||||
|
||||
li.download
|
||||
| {% if (current_user.has_cap('subscriber') or node.permissions.world) and (node.file or node.properties.files) %}
|
||||
| {% block node_download %}
|
||||
a(
|
||||
title="Download {{ node.properties.content_type | undertitle }}",
|
||||
href="{{ node.file.link }}",
|
||||
download="{{ node.file.filename }}")
|
||||
button.btn(type="button")
|
||||
i.pi-download
|
||||
| Download
|
||||
| {% endblock node_download %}
|
||||
|
||||
| {% elif current_user.has_cap('can-renew-subscription') %}
|
||||
a.btn.btn-success(
|
||||
title="Renew your subscription to download",
|
||||
target="_blank",
|
||||
href="/renew")
|
||||
i.pi-heart
|
||||
| Renew Subscription
|
||||
|
||||
| {% elif current_user.is_authenticated %}
|
||||
.btn.disabled
|
||||
i.pi-lock
|
||||
| Download
|
||||
|
||||
| {% else %}
|
||||
a.btn(
|
||||
title="Login to download {{ node.properties.content_type | undertitle }}",
|
||||
href="{{ url_for('users.login') }}")
|
||||
i.pi-lock
|
||||
| Download
|
||||
| {% endif %}
|
||||
|
||||
| {% endblock node_details %}
|
||||
.container-fluid
|
||||
.row
|
||||
| {% block node_comments %}
|
||||
.col-md-8.col-sm-12
|
||||
#comments-embed
|
||||
.comments-list-loading
|
||||
i.pi-spin
|
||||
| {% endblock node_comments %}
|
||||
|
||||
| {% block node_comments %}
|
||||
#comments-embed
|
||||
.comments-list-loading
|
||||
i.pi-spin
|
||||
| {% endblock node_comments %}
|
||||
| {% if node.properties.tags %}
|
||||
.col-md-4.d-none.d-lg-block
|
||||
script(src="{{ url_for('static_cloud', filename='assets/js/tagged_assets.min.js') }}")
|
||||
script.
|
||||
$(function() {
|
||||
$('.js-asset-list').loadTaggedAssets(4, 0);
|
||||
})
|
||||
.tagged-similar.p-3
|
||||
h6 Similar assets
|
||||
| {% for tag in node.properties.tags[:3] %}
|
||||
| {% if loop.index < 4 %}
|
||||
.card-deck.card-padless.card-deck-vertical.mx-0(
|
||||
class="js-asset-list",
|
||||
data-asset-tag="{{ tag }}")
|
||||
| {% endif %}
|
||||
| {% endfor %}
|
||||
| {% endif %}
|
||||
|
||||
| {% include 'nodes/custom/_scripts.html' %}
|
||||
|
||||
|
@@ -1,41 +1,50 @@
|
||||
| {% macro render_secondary_navigation(project, pages=None) %}
|
||||
nav.navbar-secondary
|
||||
nav.collapse.navbar-collapse
|
||||
ul.navbar-nav.navbar-right
|
||||
li
|
||||
a.navbar-item(
|
||||
href="{{ url_for('projects.view', project_url=project.url) }}",
|
||||
title="{{ project.name }} Homepage")
|
||||
span
|
||||
b {{ project.name }}
|
||||
li
|
||||
a.navbar-item(
|
||||
href="{{ url_for('main.project_blog', project_url=project.url) }}",
|
||||
title="Project Blog",
|
||||
class="{% if category == 'blog' %}active{% endif %}")
|
||||
span Blog
|
||||
| {% if pages %}
|
||||
| {% for p in pages %}
|
||||
li
|
||||
a.navbar-item(
|
||||
href="{{ url_for('projects.view_node', project_url=project.url, node_id=p._id) }}",
|
||||
title="{{ p.name }}",
|
||||
class="{% if category == 'page' %}active{% endif %}")
|
||||
span {{ p.name }}
|
||||
| {% endfor %}
|
||||
| {% endif %}
|
||||
| {% if project.nodes_featured %}
|
||||
| {# In some cases featured_nodes might might be embedded #}
|
||||
| {% if '_id' in project.nodes_featured[0] %}
|
||||
| {% set featured_node_id=project.nodes_featured[0]._id %}
|
||||
| {% else %}
|
||||
| {% set featured_node_id=project.nodes_featured[0] %}
|
||||
| {% endif %}
|
||||
li
|
||||
a.navbar-item(
|
||||
href="{{ url_for('projects.view_node', project_url=project.url, node_id=featured_node_id) }}",
|
||||
title="Explore {{ project.name }}",
|
||||
class="{% if category == 'blog' %}active{% endif %}")
|
||||
span Explore
|
||||
| {% endif %}
|
||||
include ../mixins/components
|
||||
|
||||
| {% macro render_secondary_navigation(project, navigation_links, title) %}
|
||||
|
||||
| {% if project.category == 'course' %}
|
||||
| {% set category_url = url_for('cloud.courses') %}
|
||||
| {% elif project.category == 'workshop' %}
|
||||
| {% set category_url = url_for('cloud.workshops') %}
|
||||
| {% elif project.category == 'film' %}
|
||||
| {% set category_url = url_for('cloud.open_projects') %}
|
||||
| {% else %}
|
||||
| {% set category_url = url_for('main.homepage') %}
|
||||
| {% endif %}
|
||||
|
||||
+nav-secondary()
|
||||
| {% if project.url != 'blender-cloud' %}
|
||||
| {% if not project.is_private %}
|
||||
li.text-capitalize
|
||||
a.nav-link.text-muted.px-0(href="{{ category_url }}")
|
||||
span {{ project.category }}
|
||||
li.px-1
|
||||
i.pi-angle-right
|
||||
| {% endif %}
|
||||
|
||||
+nav-secondary-link(
|
||||
class="px-1 font-weight-bold",
|
||||
href="{{url_for('projects.view', project_url=project.url, _external=True)}}")
|
||||
span {{ project.name }}
|
||||
| {% endif %}
|
||||
|
||||
| {% for link in navigation_links %}
|
||||
+nav-secondary-link(href="{{ link['url'] }}")
|
||||
| {{ link['label'] }}
|
||||
| {% endfor %}
|
||||
|
||||
| {% if project.nodes_featured %}
|
||||
| {# In some cases featured_nodes might might be embedded #}
|
||||
| {% if '_id' in project.nodes_featured[0] %}
|
||||
| {% set featured_node_id=project.nodes_featured[0]._id %}
|
||||
| {% else %}
|
||||
| {% set featured_node_id=project.nodes_featured[0] %}
|
||||
| {% endif %}
|
||||
+nav-secondary-link(
|
||||
href="{{ url_for('projects.view_node', project_url=project.url, node_id=featured_node_id) }}",
|
||||
title="Explore {{ project.name }}",
|
||||
class="{% if title == 'project' %}active{% endif %}")
|
||||
span Explore
|
||||
| {% endif %}
|
||||
|
||||
| {% endmacro %}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user