diff --git a/.browserslistrc b/.browserslistrc
deleted file mode 100644
index e94f8140c..000000000
--- a/.browserslistrc
+++ /dev/null
@@ -1 +0,0 @@
-defaults
diff --git a/.coffeelint.json b/.coffeelint.json
deleted file mode 100644
index 6bfd6487f..000000000
--- a/.coffeelint.json
+++ /dev/null
@@ -1,135 +0,0 @@
-{
- "arrow_spacing": {
- "level": "warn"
- },
- "braces_spacing": {
- "level": "warn",
- "spaces": 1,
- "empty_object_spaces": 0
- },
- "camel_case_classes": {
- "level": "error"
- },
- "coffeescript_error": {
- "level": "error"
- },
- "colon_assignment_spacing": {
- "level": "warn",
- "spacing": {
- "left": 0,
- "right": 1
- }
- },
- "cyclomatic_complexity": {
- "level": "warn",
- "value": 10
- },
- "duplicate_key": {
- "level": "error"
- },
- "empty_constructor_needs_parens": {
- "level": "warn"
- },
- "ensure_comprehensions": {
- "level": "warn"
- },
- "eol_last": {
- "level": "warn"
- },
- "indentation": {
- "value": 2,
- "level": "error"
- },
- "line_endings": {
- "level": "warn",
- "value": "unix"
- },
- "max_line_length": {
- "value": 80,
- "level": "ignore",
- "limitComments": true
- },
- "missing_fat_arrows": {
- "level": "ignore"
- },
- "newlines_after_classes": {
- "value": 3,
- "level": "warn"
- },
- "no_backticks": {
- "level": "error"
- },
- "no_debugger": {
- "level": "warn",
- "console": false
- },
- "no_empty_functions": {
- "level": "warn"
- },
- "no_empty_param_list": {
- "level": "warn"
- },
- "no_implicit_braces": {
- "level": "ignore",
- "strict": true
- },
- "no_implicit_parens": {
- "level": "ignore",
- "strict": true
- },
- "no_interpolation_in_single_quotes": {
- "level": "warn"
- },
- "no_nested_string_interpolation": {
- "level": "warn"
- },
- "no_plusplus": {
- "level": "warn"
- },
- "no_private_function_fat_arrows": {
- "level": "warn"
- },
- "no_stand_alone_at": {
- "level": "warn"
- },
- "no_tabs": {
- "level": "error"
- },
- "no_this": {
- "level": "warn"
- },
- "no_throwing_strings": {
- "level": "error"
- },
- "no_trailing_semicolons": {
- "level": "error"
- },
- "no_trailing_whitespace": {
- "level": "error",
- "allowed_in_comments": false,
- "allowed_in_empty_lines": true
- },
- "no_unnecessary_double_quotes": {
- "level": "warn"
- },
- "no_unnecessary_fat_arrows": {
- "level": "warn"
- },
- "non_empty_constructor_needs_parens": {
- "level": "warn"
- },
- "prefer_english_operator": {
- "level": "ignore",
- "doubleNotLevel": "warn"
- },
- "space_operators": {
- "level": "warn"
- },
- "spacing_after_comma": {
- "level": "warn"
- },
- "transform_messes_up_line_numbers": {
- "level": "warn"
- }
- }
-
\ No newline at end of file
diff --git a/.erb-lint.yml b/.config/.erb-lint.yml
similarity index 90%
rename from .erb-lint.yml
rename to .config/.erb-lint.yml
index 48d5f74d1..5b8aba745 100644
--- a/.erb-lint.yml
+++ b/.config/.erb-lint.yml
@@ -1,3 +1,4 @@
+# Not used right now
---
EnableDefaultLinters: true
linters:
diff --git a/.rubocop.yml b/.config/.rubocop.yml
similarity index 99%
rename from .rubocop.yml
rename to .config/.rubocop.yml
index c625d63ec..cc781ea1b 100644
--- a/.rubocop.yml
+++ b/.config/.rubocop.yml
@@ -105,7 +105,7 @@ Style/MethodCallWithArgsParentheses:
AllowedPatterns: [^redirect_]
# Don't enforce in migrations, as we have methods like `add_column`,
# `change_column` etc. and parentheses would be very annoying there.
- Exclude: ["db/**/*"]
+ Exclude: ["../db/**/*"]
Style/RedundantReturn:
AllowMultipleReturnValues: true
diff --git a/.config/README.md b/.config/README.md
new file mode 100644
index 000000000..f71a48028
--- /dev/null
+++ b/.config/README.md
@@ -0,0 +1,6 @@
+# Config settings
+
+This directory contains configuration files for the project according to the [`.config/` directory proposal](https://github.com/pi0/config-dir).
+
+Note that we currently don't use these files (but might in the near future):
+- `.erb-lint.myl`
diff --git a/eslint.config.mjs b/.config/eslint.mjs
similarity index 58%
rename from eslint.config.mjs
rename to .config/eslint.mjs
index f037398b8..c7a82fe39 100644
--- a/eslint.config.mjs
+++ b/.config/eslint.mjs
@@ -33,6 +33,59 @@ const customGlobals = {
// Common global methods
initBootstrapPopovers: "readable",
+
+ // Thyme & Annotation tool globals
+ // TODO: This is a "hack" right now to get rid of "xy is not defined" error
+ // messages in ESLint.
+ // In an ideal world, we would use the new ES6 module syntax, but that is a
+ // bigger undertaking as we have to get rid of rails webpacker and use
+ // webpack itself or even better try to use the new import maps.
+ // See the links in this issue: https://github.com/MaMpf-HD/mampf/issues/454
+ thyme: "readable",
+ video: "readable",
+ thymeAttributes: "readable",
+ thymeKeyShortcuts: "readable",
+ thymeUtility: "readable",
+ Resizer: "readable",
+
+ ControlBarHider: "readable",
+
+ ChapterManager: "readable",
+ DisplayManager: "readable",
+ MetadataManager: "readable",
+
+ Component: "readable",
+ Category: "readable",
+ CategoryEnum: "readable",
+ Subcategory: "readable",
+ VolumeBar: "readable",
+ TimeButton: "readable",
+ MuteButton: "readable",
+ PlayButton: "readable",
+ SeekBar: "readable",
+ FullScreenButton: "readable",
+ NextChapterButton: "readable",
+ PreviousChapterButton: "readable",
+ SpeedSelector: "readable",
+ AddItemButton: "readable",
+ AddReferenceButton: "readable",
+ AddScreenshotButton: "readable",
+ IaBackButton: "readable",
+ IaButton: "readable",
+ IaCloseButton: "readable",
+
+ seekBar: "writable",
+
+ Annotation: "readable",
+ AnnotationManager: "readable",
+ AnnotationArea: "readable",
+ AnnotationsToggle: "readable",
+ AnnotationCategoryToggle: "readable",
+ AnnotationButton: "readable",
+ Heatmap: "readable",
+
+ // KaTeX
+ renderMathInElement: "readable",
};
// We don't have cypress linting yet, as the Cypress ESLint plugin
@@ -43,7 +96,7 @@ export default [
js.configs.recommended,
// Allow linting of ERB files, see https://github.com/Splines/eslint-plugin-erb
erb.configs.recommended,
- // Globally ignore the following files
+ // Globally ignore the following paths
{
ignores: [
"node_modules/",
@@ -82,5 +135,9 @@ export default [
...globals.node,
},
},
+ linterOptions: {
+ // see https://github.com/Splines/eslint-plugin-erb/releases/tag/v2.0.1
+ reportUnusedDisableDirectives: "off",
+ },
},
];
diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml
index e40132f75..230aafd40 100644
--- a/.github/workflows/linter.yml
+++ b/.github/workflows/linter.yml
@@ -72,4 +72,4 @@ jobs:
if: ${{ steps.js-changed.outputs.changed-files != ''}}
run: |
echo "🚨 Running ESLint version: $(yarn run --silent eslint --version)"
- yarn run eslint --max-warnings 0 --no-warn-ignored ${{ steps.js-changed.outputs.changed-files }}
+ yarn run eslint --config ./.config/eslint.mjs --max-warnings 0 --no-warn-ignored ${{ steps.js-changed.outputs.changed-files }}
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index a1fdba20e..000000000
--- a/.travis.yml
+++ /dev/null
@@ -1,15 +0,0 @@
-language: ruby
-service:
- - docker
-
-
-before_install:
- - cd docker/run_tests/
-
-install:
- - docker-compose build
-
-before_script:
-
-script:
- - docker-compose up --abort-on-container-exit
diff --git a/.vscode/settings.json b/.vscode/settings.json
index abceb59fc..9dcca743d 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -16,6 +16,9 @@
"eslint.experimental.useFlatConfig": true,
// this disables VSCode built-in formatter (instead we want to use ESLint)
"javascript.validate.enable": false,
+ "eslint.options": {
+ "overrideConfigFile": ".config/eslint.mjs"
+ },
//////////////////////////////////////
// HTML
//////////////////////////////////////
@@ -30,7 +33,9 @@
"editor.formatOnSave": true
},
"rubyLsp.formatter": "rubocop",
- "rubyLsp.rubyVersionManager": "rbenv",
+ "rubyLsp.rubyVersionManager": {
+ "identifier": "rbenv"
+ },
"rubyLsp.enabledFeatures": {
"codeActions": true,
"diagnostics": true,
@@ -89,9 +94,18 @@
}
],
//////////////////////////////////////
+ // Git
+ //////////////////////////////////////
+ "git.inputValidation": true,
+ "git.inputValidationSubjectLength": 50,
+ "git.inputValidationLength": 72,
+ //////////////////////////////////////
// Spell Checker
//////////////////////////////////////
"cSpell.words": [
+ "commontator",
+ "helpdesk",
"turbolinks"
- ]
+ ],
+ "rubyLsp.customRubyCommand": "set -o allexport && . ./docker-dummy.env && set +o allexport"
}
\ No newline at end of file
diff --git a/Gemfile b/Gemfile
index 75196656d..9f5345bf5 100644
--- a/Gemfile
+++ b/Gemfile
@@ -111,9 +111,9 @@ group :development, :docker_development do
# Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring
gem "marcel"
gem "pgreset"
- gem "rubocop", "~> 1.57", require: false
- gem "rubocop-performance", "~> 1.16", require: false
- gem "rubocop-rails", "~> 2.22", ">= 2.22.1", require: false
+ gem "rubocop", "~> 1.63", require: false
+ gem "rubocop-performance", "~> 1.21", require: false
+ gem "rubocop-rails", "~> 2.24", require: false
gem "spring"
gem "spring-watcher-listen", "~> 2.0.0"
# gem 'bullet'
diff --git a/Gemfile.lock b/Gemfile.lock
index 88161ce1b..43d0eb2c9 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -284,7 +284,7 @@ GEM
js-routes (1.4.9)
railties (>= 4)
sprockets-rails
- json (2.6.3)
+ json (2.7.2)
jsonapi-renderer (0.2.2)
kaminari (1.2.2)
activesupport (>= 4.1.0)
@@ -359,8 +359,8 @@ GEM
options (2.3.2)
orm_adapter (0.5.0)
pairing_heap (3.0.0)
- parallel (1.23.0)
- parser (3.2.2.4)
+ parallel (1.24.0)
+ parser (3.3.0.5)
ast (~> 2.4.1)
racc
pdf-reader (2.11.0)
@@ -443,7 +443,7 @@ GEM
ffi (~> 1.0)
redis-client (0.14.1)
connection_pool
- regexp_parser (2.8.2)
+ regexp_parser (2.9.0)
request_store (1.5.1)
rack (>= 1.4)
responders (3.1.0)
@@ -486,26 +486,36 @@ GEM
rspec-mocks (~> 3.11)
rspec-support (~> 3.11)
rspec-support (3.12.0)
- rubocop (1.57.2)
+ rubocop (1.63.1)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
- parser (>= 3.2.2.4)
+ parser (>= 3.3.0.2)
+ parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
- rubocop-ast (>= 1.28.1, < 2.0)
+ rubocop-ast (>= 1.31.1, < 2.0)
+ rubocop-ast (>= 1.31.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
- rubocop-ast (1.30.0)
- parser (>= 3.2.1.0)
- rubocop-performance (1.19.1)
- rubocop (>= 1.7.0, < 2.0)
- rubocop-ast (>= 0.4.0)
- rubocop-rails (2.22.1)
+ rubocop-ast (1.31.2)
+ parser (>= 3.3.0.4)
+ rubocop-performance (1.21.0)
+ rubocop (>= 1.48.1, < 2.0)
+ rubocop-ast (>= 1.31.1, < 2.0)
+ rubocop-rails (2.24.1)
+ rubocop-ast (1.31.2)
+ parser (>= 3.3.0.4)
+ rubocop-performance (1.21.0)
+ rubocop (>= 1.48.1, < 2.0)
+ rubocop-ast (>= 1.31.1, < 2.0)
+ rubocop-rails (2.24.1)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0)
+ rubocop-ast (>= 1.31.1, < 2.0)
+ rubocop-ast (>= 1.31.1, < 2.0)
ruby-graphviz (1.2.5)
rexml
ruby-progressbar (1.13.0)
@@ -704,9 +714,9 @@ DEPENDENCIES
rqrcode
rspec-github
rspec-rails
- rubocop (~> 1.57)
- rubocop-performance (~> 1.16)
- rubocop-rails (~> 2.22, >= 2.22.1)
+ rubocop (~> 1.63)
+ rubocop-performance (~> 1.21)
+ rubocop-rails (~> 2.24)
rubyzip (~> 2.3.0)
sass-rails (>= 6)
selenium-webdriver
diff --git a/app/abilities/annotation_ability.rb b/app/abilities/annotation_ability.rb
new file mode 100644
index 000000000..1d95ba944
--- /dev/null
+++ b/app/abilities/annotation_ability.rb
@@ -0,0 +1,11 @@
+class AnnotationAbility
+ include CanCan::Ability
+
+ def initialize(user)
+ can [:edit, :update, :destroy], Annotation do |annotation|
+ annotation.user == user
+ end
+
+ can [:new, :create, :update_annotations, :num_nearby_posted_mistake_annotations], Annotation
+ end
+end
diff --git a/app/abilities/answer_ability.rb b/app/abilities/answer_ability.rb
index f4b41b3c1..a88cc96ee 100644
--- a/app/abilities/answer_ability.rb
+++ b/app/abilities/answer_ability.rb
@@ -4,10 +4,8 @@ class AnswerAbility
def initialize(user)
clear_aliased_actions
- can [:new, :create, :update, :destroy], Answer do |answer|
+ can [:new, :create, :update, :destroy, :cancel_edit], Answer do |answer|
answer.question.present? && user.can_edit?(answer.question)
end
-
- can :update_answer_box, Answer
end
end
diff --git a/app/abilities/medium_ability.rb b/app/abilities/medium_ability.rb
index 01c767b9c..41d068288 100644
--- a/app/abilities/medium_ability.rb
+++ b/app/abilities/medium_ability.rb
@@ -5,7 +5,7 @@ def initialize(user)
user ||= User.new
clear_aliased_actions
- can [:index, :new, :search], Medium
+ can [:index, :new, :search, :check_annotation_visibility], Medium
can [:show, :show_comments], Medium do |medium|
medium.visible_for_user?(user) &&
@@ -16,7 +16,7 @@ def initialize(user)
!user.generic? && medium.visible_for_user?(user)
end
- can [:edit, :update, :enrich, :publish, :destroy, :cancel_publication,
+ can [:edit, :update, :enrich, :feedback, :publish, :destroy, :cancel_publication,
:add_item, :add_reference, :add_screenshot, :remove_screenshot,
:import_script_items, :import_manuscript, :statistics,
:render_medium_tags, :fill_quizzable_area,
diff --git a/app/assets/javascripts/answers.coffee b/app/assets/javascripts/answers.coffee
deleted file mode 100644
index c7ea9d8c4..000000000
--- a/app/assets/javascripts/answers.coffee
+++ /dev/null
@@ -1,69 +0,0 @@
-# Place all the behaviors and hooks related to the matching controller here.
-# All this logic will automatically be available in application.js.
-# You can use CoffeeScript in this file: http://coffeescript.org/
-
-# change button 'Bearbeiten' to 'verwerfen' after answer body is revealed
-
-$(document).on 'turbolinks:load', ->
-
- $(document).on 'shown.bs.collapse', '[id^="collapse-answer-"]', ->
- $target = $('#targets-answer-' + $(this).data('id'))
- $target.empty().append($target.data('discard'))
- .removeClass('btn-primary').addClass('btn-secondary')
- return
-
- # submit form to database after answer body is hidden and restore
- # buttons after after answer body is hidden
-
- $(document).on 'hidden.bs.collapse', '[id^="collapse-answer-"]', ->
- answerId = $(this).data('id')
- text = $('#tex-area-answer-' + answerId).val()
- value = $('#answer-true-' + answerId).is(':checked')
- explanation = $('#tex-area-explanation-' + answerId).val()
- $target = $('#targets-answer-' + answerId)
- $target.empty()
- .append($target.data('edit'))
- .removeClass('btn-secondary').addClass 'btn-primary'
- $.ajax Routes.answer_path(answerId),
- type: 'PATCH'
- dataType: 'script'
- data: {
- answer: {
- text: text
- value: value
- explanation: explanation
- }
- }
- return
-
- # change correctness box for answer if radio button is clicked
-
- $(document).on 'change', '[id^="answer-value-"]', ->
- $.ajax Routes.update_answer_box_path(),
- type: 'GET'
- dataType: 'script'
- data: {
- answer_id: $(this).data('id')
- value: $('#answer-true-' + $(this).data('id')).is(':checked')
- }
- error: (jqXHR, textStatus, errorThrown) ->
- console.log("AJAX Error: #{textStatus}")
- return
-
- # remove card for new answer if creation of new answer is cancelled
-
- $(document).on 'click', '#new-answer-cancel', ->
- $('#new-answer').show()
- $('#new-answer-field').empty()
- return
-
- return
-
-# clean up everything before turbolinks caches
-$(document).on 'turbolinks:before-cache', ->
- $(document).off 'shown.bs.collapse', '[id^="collapse-answer-"]'
- $(document).off 'hidden.bs.collapse', '[id^="collapse-answer-"]'
- $(document).off 'change', '[id^="answer-value-"]'
- $(document).off 'click', '#new-answer-cancel'
- return
-
diff --git a/app/assets/javascripts/answers.coffee.erb b/app/assets/javascripts/answers.coffee.erb
new file mode 100644
index 000000000..90e037a20
--- /dev/null
+++ b/app/assets/javascripts/answers.coffee.erb
@@ -0,0 +1,90 @@
+<% environment.context_class.instance_eval { include ApplicationHelper } %>
+
+# The "target button" is either the "discard" button or the "edit" button
+# what its purpose is is stored in this object with a mapping:
+# answerId -> boolean
+targetButtonIsDiscardButton = {}
+
+# Set of answer ids that have a discard listener registered
+# This is to avoid registering the same listener multiple times.
+window.registeredDiscardListeners = new Set();
+
+$(document).on 'turbolinks:load', ->
+
+ $(document).on 'shown.bs.collapse', '[id^="collapse-answer-"]', ->
+ # Answer is now shown to the user and can be edited
+ answerId = $(this).data('id');
+ registerDiscardListeners();
+ targetButtonIsDiscardButton[answerId] = true;
+ $target = $('#targets-answer-' + answerId)
+ $target.empty().append($target.data('discard'))
+ .removeClass('btn-primary').addClass('btn-secondary')
+
+ $(document).on 'hidden.bs.collapse', '[id^="collapse-answer-"]', ->
+ # Answer is now hidden from the user
+ answerId = $(this).data('id')
+ targetButtonIsDiscardButton[answerId] = false;
+ $target = $('#targets-answer-' + answerId)
+ $target.empty().append($target.data('edit'))
+ .removeClass('btn-secondary').addClass('btn-primary')
+
+ # Appearance of box
+ $(document).on 'change', '[id^="answer-value-"]', ->
+ id = $(this).data('id')
+ isCorrectAnswer = $('#answer-true-' + id).is(':checked')
+
+ # Set background color
+ if isCorrectAnswer
+ newClass = "<%= bgcolor(true) %>";
+ else
+ newClass = "<%= bgcolor(false) %>";
+ $('#answer-header-' + id)
+ .removeClass('bg-correct')
+ .removeClass('bg-incorrect')
+ .addClass(newClass)
+
+ # Set ballot box
+ answerBox = $('#answer-box-' + id)
+ answerBox.empty()
+ if isCorrectAnswer
+ answerBox.append '<%= ballot_box(true) %>'
+ else
+ answerBox.append '<%= ballot_box(false) %>'
+
+ # Cancel new answer creation
+ $(document).on 'click', '#new-answer-cancel', ->
+ $('#new-answer').show()
+ $('#new-answer-field').empty()
+
+# clean up everything before turbolinks caches
+$(document).on 'turbolinks:before-cache', ->
+ $(document).off 'shown.bs.collapse', '[id^="collapse-answer-"]'
+ $(document).off 'hidden.bs.collapse', '[id^="collapse-answer-"]'
+ $(document).off 'change', '[id^="answer-value-"]'
+ $(document).off 'click', '#new-answer-cancel'
+
+
+registerDiscardListeners = () ->
+ buttons = $('[id^=targets-answer-]');
+ $.each(buttons, (i,btn) ->
+ btn = $(btn);
+ answerId = btn.attr('id').split('-')[2];
+
+ # Don't register listeners multiple times
+ if answerId in window.registeredDiscardListeners
+ return;
+
+ window.registeredDiscardListeners.add(answerId);
+ $(this).on('click', (evt) =>
+ isDiscardButton = targetButtonIsDiscardButton[answerId];
+ if not isDiscardButton
+ return;
+
+ # On discard
+ $.ajax Routes.cancel_edit_answer_path(answerId),
+ type: 'GET'
+ dataType: 'script'
+ error: (jqXHR, textStatus, errorThrown) ->
+ console.log("AJAX Error: #{textStatus}")
+ );
+ );
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index 9834213e1..49b7e76e6 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -50,10 +50,53 @@
//= require talks
//= require terms
//= require tex_preview
-//= require thyme
-//= require thyme_editor
//= require upload
//= require users
//= require vertices
//= require watchlists
//= require turbolinks
+
+//= require search_tags
+
+/*
+ * THYME RELATED SCRIPTS
+ * (warning: the order of the scripts is important
+ * - do not switch to alphabetical order!)
+ */
+//= require thyme/components/component
+//= require thyme/components/add_item_button
+//= require thyme/components/add_reference_button
+//= require thyme/components/add_screenshot_button
+//= require thyme/components/annotation_category_toggle
+//= require thyme/components/annotations_toggle
+//= require thyme/components/annotation_button
+//= require thyme/components/full_screen_button
+//= require thyme/components/ia_button
+//= require thyme/components/ia_back_button
+//= require thyme/components/ia_close_button
+//= require thyme/components/mute_button
+//= require thyme/components/next_chapter_button
+//= require thyme/components/play_button
+//= require thyme/components/previous_chapter_button
+//= require thyme/components/seek_bar
+//= require thyme/components/speed_selector
+//= require thyme/components/time_button
+//= require thyme/components/volume_bar
+//= require thyme/annotations/category_enum
+//= require thyme/annotations/category
+//= require thyme/annotations/subcategory
+//= require thyme/annotations/annotation
+//= require thyme/annotations/annotation_area
+//= require thyme/annotations/annotation_manager
+//= require thyme/attributes
+//= require thyme/chapter_manager
+//= require thyme/control_bar_hider
+//= require thyme/display_manager
+//= require thyme/heatmap
+//= require thyme/key_shortcuts
+//= require thyme/metadata_manager
+//= require thyme/resizer
+//= require thyme/utility
+//= require thyme/thyme_player
+//= require thyme/thyme_editor
+//= require thyme/thyme_feedback
diff --git a/app/assets/javascripts/feedback.js b/app/assets/javascripts/feedback.js
new file mode 100644
index 000000000..7d9ab3772
--- /dev/null
+++ b/app/assets/javascripts/feedback.js
@@ -0,0 +1,74 @@
+$(document).on("turbolinks:load", () => {
+ if (!shouldRegisterFeedback()) {
+ return;
+ }
+ registerToasts();
+ registerSubmitButtonHandler();
+ registerFeedbackBodyValidator();
+});
+
+var SUBMIT_FEEDBACK_ID = "#submit-feedback";
+
+var TOAST_OPTIONS = {
+ animation: true,
+ autohide: true,
+ delay: 6000, // autohide after ... milliseconds
+};
+
+function shouldRegisterFeedback() {
+ return $(SUBMIT_FEEDBACK_ID).length > 0;
+}
+
+function registerToasts() {
+ const toastElements = document.querySelectorAll(".toast");
+ [...toastElements].map((toast) => {
+ new bootstrap.Toast(toast, TOAST_OPTIONS);
+ });
+}
+
+function registerSubmitButtonHandler() {
+ // Invoke the hidden submit button inside the actual Rails form
+ $("#submit-feedback-form-btn-outside").click(() => {
+ submitFeedback();
+ });
+
+ // Submit form by pressing Ctrl + Enter
+ document.addEventListener("keydown", (event) => {
+ const isModalOpen = $(SUBMIT_FEEDBACK_ID).is(":visible");
+ if (isModalOpen && event.ctrlKey && event.key == "Enter") {
+ submitFeedback();
+ }
+ });
+}
+
+function registerFeedbackBodyValidator() {
+ const feedbackBody = document.getElementById("feedback_feedback");
+ feedbackBody.addEventListener("input", () => {
+ validateFeedback();
+ });
+}
+
+function validateFeedback() {
+ const feedbackBody = document.getElementById("feedback_feedback");
+ const validityState = feedbackBody.validity;
+ if (validityState.tooShort) {
+ const tooShortMessage = feedbackBody.dataset.tooShortMessage;
+ feedbackBody.setCustomValidity(tooShortMessage);
+ }
+ else if (validityState.valueMissing) {
+ const valueMissingMessage = feedbackBody.dataset.valueMissingMessage;
+ feedbackBody.setCustomValidity(valueMissingMessage);
+ }
+ else {
+ // render input valid, so that form will submit
+ feedbackBody.setCustomValidity("");
+ }
+
+ feedbackBody.reportValidity();
+}
+
+function submitFeedback() {
+ const submitButton = $("#submit-feedback-form-btn");
+ validateFeedback();
+ submitButton.click();
+}
diff --git a/app/assets/javascripts/lectures.coffee b/app/assets/javascripts/lectures.coffee
index 15dfacca0..6e8d60759 100644
--- a/app/assets/javascripts/lectures.coffee
+++ b/app/assets/javascripts/lectures.coffee
@@ -103,13 +103,13 @@ $(document).on 'turbolinks:load', ->
# hide the media tab if hide media button is clicked
$('#hide-media-button').on 'click', ->
$('#lecture-media-card').hide()
- $('#lecture-content-card').removeClass('col-xxxl-9')
+ $('#lecture-content-card').removeClass('col-xxl-9')
$('#show-media-button').show()
return
# display the media tab if show media button is clicked
$('#show-media-button').on 'click', ->
- $('#lecture-content-card').addClass('col-xxxl-9')
+ $('#lecture-content-card').addClass('col-xxl-9')
$('#lecture-media-card').show()
$('#show-media-button').hide()
return
@@ -136,7 +136,6 @@ $(document).on 'turbolinks:load', ->
tags = $(this).data('tags')
for t in tags
$('.lecture-tag[data-id="'+t+'"]').removeClass('bg-warning')
- .addClass('bg-light')
return
# mouseenter over lesson -> colorize tags
@@ -203,7 +202,6 @@ $(document).on 'turbolinks:load', ->
userModalContent.dataset.filled = 'true'
return
-
# on small mobile display, use shortened tag badges and
# shortened course titles
mobileDisplay = ->
@@ -214,6 +212,7 @@ $(document).on 'turbolinks:load', ->
$('#secondnav').show()
$('#lecturesDropdown').appendTo($('#secondnav'))
$('#notificationDropdown').appendTo($('#secondnav'))
+ $('#feedback-btn').appendTo($('#secondnav'))
$('#searchField').appendTo($('#secondnav'))
$('#second-admin-nav').show()
$('#adminDetails').appendTo($('#second-admin-nav'))
@@ -236,6 +235,7 @@ $(document).on 'turbolinks:load', ->
$('#secondnav').hide()
$('#lecturesDropdown').appendTo($('#firstnav'))
$('#notificationDropdown').appendTo($('#firstnav'))
+ $('#feedback-btn').appendTo($('#firstnav'))
$('#searchField').appendTo($('#firstnav'))
$('#second-admin-nav').hide()
$('#teachableDrop').appendTo($('#first-admin-nav'))
diff --git a/app/assets/javascripts/search_tags.js b/app/assets/javascripts/search_tags.js
new file mode 100644
index 000000000..2aa798305
--- /dev/null
+++ b/app/assets/javascripts/search_tags.js
@@ -0,0 +1,19 @@
+$(document).on("turbolinks:load", function () {
+ $("#search_all_tags").change(evt => toggleSearchAllTags(evt));
+});
+
+/**
+ * Dynamically enable/disable the OR/AND buttons in the media search form.
+ * If the user has decided to search for media regardless of the tags,
+ * i.e. they enable the "all" (tags) button, we disable the "OR/AND" buttons
+ * as it is pointless to search for media that references *all* available tags
+ * at once.
+ */
+function toggleSearchAllTags(evt) {
+ const searchAllTags = evt.target.checked;
+ if (searchAllTags) {
+ $("#search_tag_operator_or").prop("checked", true);
+ }
+ $("#search_tag_operator_or").prop("disabled", searchAllTags);
+ $("#search_tag_operator_and").prop("disabled", searchAllTags);
+}
diff --git a/app/assets/javascripts/thyme.coffee b/app/assets/javascripts/thyme.coffee
deleted file mode 100644
index 65f8a7a8d..000000000
--- a/app/assets/javascripts/thyme.coffee
+++ /dev/null
@@ -1,757 +0,0 @@
-# convert time in seconds to string of the form H:MM:SS
-secondsToTime = (seconds) ->
- date = new Date(null)
- date.setSeconds seconds
- return date.toISOString().substr(12, 7)
-
-# return the start time of the next chapter relative to a given time in seconds
-nextChapterStart = (seconds) ->
- chapters = document.getElementById('chapters')
- times = JSON.parse(chapters.dataset.times)
- return if times.length == 0
- i = 0
- while i < times.length
- return times[i] if times[i] > seconds
- ++i
- return
-
-# return the start time of the previous chapter relative to a givben time in
-# seconds
-previousChapterStart = (seconds) ->
- chapters = document.getElementById('chapters')
- times = JSON.parse(chapters.dataset.times)
- return if times.length == 0
- i = times.length - 1
- while i > -1
- if times[i] < seconds
- return times[i] if seconds - times[i] > 3
- return times[i-1] if i > 0
- --i
- return
-
-showControlBar = ->
- $('#video-controlBar').css('visibility', 'visible')
- $('#video').css('cursor', '')
- return
-
-hideControlBar = ->
- $('#video-controlBar').css('visibility', 'hidden')
- $('#video').css('cursor', 'none')
- return
-
-# hide control bar after 3 seconds of inactivity
-idleHideControlBar = ->
- t = undefined
-
- resetTimer = ->
- clearTimeout t
- t = setTimeout hideControlBar, 3000
- return
-
- window.onload = resetTimer
- window.onmousemove = resetTimer
- window.onmousedown = resetTimer
- window.ontouchstart = resetTimer
- window.onclick = resetTimer
- return
-
-# material icons that represent different media types
-iconClass = (type) ->
- if type == 'video'
- return 'video_library'
- else if type == 'text'
- return 'library_books'
- else if type == 'quiz'
- return 'games'
- else if type == 'info'
- return 'info'
- return
-
-# returns the jQuery object of all metadata elements that start after the
-# given time in seconds
-metadataAfter = (seconds) ->
- metaList = document.getElementById('metadata')
- times = JSON.parse(metaList.dataset.times)
- return $() if times.length == 0
- i = 0
- while i < times.length
- if times[i] > seconds
- $nextMeta = $('#m-' + $.escapeSelector(times[i]))
- return $nextMeta.add($nextMeta.nextAll())
- ++i
- return $()
-
-# returns the jQuery object of all metadata elements that start before the
-# given time in seconds
-metadataBefore = (seconds) ->
- return $('[id^="m-"]').not(metadataAfter(seconds))
-
-# for a given time, show all metadata elements that start before this time
-# and hide all that start later
-metaIntoView = (time) ->
- metadataAfter(time).hide()
- $before = metadataBefore(time)
- $before.show()
- previousLength = $before.length
- if previousLength > 0
- $before.get(previousLength - 1).scrollIntoView()
- return
-
-# set up everything: read out track data and initialize html elements
-setupHypervideo = ->
- $chapterList = $('#chapters')
- $metaList = $('#metadata')
- video = $('#video').get 0
- backButton = document.getElementById('back-button')
- return if !video?
- document.body.style.backgroundColor = 'black'
- chaptersElement = $('#video track[kind="chapters"]').get 0
- metadataElement = $('#video track[kind="metadata"]').get 0
-
- # set up back button (transports back to the current chapter)
- displayBackButton = ->
- backButton.dataset.time = video.currentTime
- currentChapter = $('#chapters .current')
- if currentChapter.length > 0
- backInfo = currentChapter.data('text').split(':', 1)[0]
- if backInfo? && backInfo.length > 20
- backInfo = backButton.dataset.back
- else
- backInfo = backButton.dataset.backto + backInfo
- $(backButton).empty().append(backInfo).show()
- renderMathInElement backButton,
- delimiters: [
- {
- left: '$$'
- right: '$$'
- display: true
- }
- {
- left: '$'
- right: '$'
- display: false
- }
- {
- left: '\\('
- right: '\\)'
- display: false
- }
- {
- left: '\\['
- right: '\\]'
- display: true
- }
- ]
- throwOnError: false
- return
-
- # set up the chapter elements
- displayChapters = ->
- if chaptersElement.readyState == 2 and
- (chaptersTrack = chaptersElement.track)
- chaptersTrack.mode = 'hidden'
- i = 0
- times = []
- # read out the chapter track cues and generate html elements for chapters,
- # run katex on them
- while i < chaptersTrack.cues.length
- cue = chaptersTrack.cues[i]
- chapterName = cue.text
- start = cue.startTime
- times.push start
- $listItem = $("
")
- $link = $(" ", { id: 'c-' + start, text: chapterName })
- $chapterList.append($listItem.append($link))
- chapterElement = $link.get(0)
- renderMathInElement chapterElement,
- delimiters: [
- {
- left: '$$'
- right: '$$'
- display: true
- }
- {
- left: '$'
- right: '$'
- display: false
- }
- {
- left: '\\('
- right: '\\)'
- display: false
- }
- {
- left: '\\['
- right: '\\]'
- display: true
- }
- ]
- throwOnError: false
- $link.data('text', chapterName)
- # if a chapter element is clicked, transport to chapter start time
- $link.on 'click', ->
- displayBackButton()
- video.currentTime = @id.replace('c-', '')
- return
- ++i
- # store start times as data attribute
- $chapterList.get(0).dataset.times = JSON.stringify(times)
- $chapterList.show()
- # if the chapters cue changes (i.e. a switch between chapters), highlight
- # current chapter elment and scroll it into view, remove highlighting from
- # old chapter
- $(chaptersTrack).on 'cuechange', ->
- $('#chapters li a').removeClass 'current'
- if @activeCues.length > 0
- activeStart = @activeCues[0].startTime
- if chapter = document.getElementById('c-' + activeStart)
- $(chapter).addClass 'current'
- chapter.scrollIntoView()
- return
- return
-
- # set up the metadata elements
- displayMetadata = ->
- if metadataElement.readyState == 2 and (metaTrack = metadataElement.track)
- metaTrack.mode = 'hidden'
- i = 0
- times = []
- # read out the metadata track cues and generate html elements for
- # metadata, run katex on them
- while i < metaTrack.cues.length
- cue = metaTrack.cues[i]
- meta = JSON.parse cue.text
- start = cue.startTime
- times.push start
- $listItem = $(' ', id: 'm-' + start)
- $listItem.hide()
- $link = $(' ',
- text: meta.reference
- class: 'item'
- id: 'l-' + start)
- $videoIcon = $(' ',
- text: 'video_library'
- class: 'material-icons')
- $videoRef = $(' ',
- href: meta.video
- target: '_blank')
- $videoRef.append($videoIcon)
- $videoRef.hide() unless meta.video?
- $manIcon = $(' ',
- text: 'library_books'
- class: 'material-icons')
- $manRef = $(' ',
- href: meta.manuscript
- target: '_blank')
- $manRef.append($manIcon)
- $manRef.hide() unless meta.manuscript?
- $scriptIcon = $(' ',
- text: 'menu_book'
- class: 'material-icons')
- $scriptRef = $(' ',
- href: meta.script
- target: '_blank')
- $scriptRef.append($scriptIcon)
- $scriptRef.hide() unless meta.script?
- $quizIcon = $(' ',
- text: 'videogame_asset'
- class: 'material-icons')
- $quizRef = $(' ',
- href: meta.quiz
- target: '_blank')
- $quizRef.append($quizIcon)
- $quizRef.hide() unless meta.quiz?
- $extIcon = $(' ',
- text: 'link'
- class: 'material-icons')
- $extRef = $(' ',
- href: meta.link
- target: '_blank')
- $extRef.append($extIcon)
- $extRef.hide() unless meta.link?
- $description = $('
',
- text: meta.text
- class: 'mx-3')
- $explanation = $('
',
- text: meta.explanation
- class: 'm-3')
- $details = $('
')
- $details.append($link).append($description).append($explanation)
- $icons = $('
',
- style: 'flex-shrink: 3; display: flex; flex-direction: column;')
- $icons.append($videoRef).append($manRef).append($scriptRef).append($quizRef).append($extRef)
- $listItem.append($details).append($icons)
- $metaList.append($listItem)
- $videoRef.on 'click', ->
- video.pause()
- return
- $manRef.on 'click', ->
- video.pause()
- return
- $extRef.on 'click', ->
- video.pause()
- return
- $link.on 'click', ->
- displayBackButton()
- video.currentTime = this.id.replace('l-','')
- return
- metaElement = $listItem.get(0)
- renderMathInElement metaElement,
- delimiters: [
- {
- left: '$$'
- right: '$$'
- display: true
- }
- {
- left: '$'
- right: '$'
- display: false
- }
- {
- left: '\\('
- right: '\\)'
- display: false
- }
- {
- left: '\\['
- right: '\\]'
- display: true
- }
- ]
- throwOnError: false
- ++i
- # store metadata start times as data attribute
- $metaList.get(0).dataset.times = JSON.stringify(times)
- # if user jumps to a new position in the video, display all metadata
- # that start before this time and hide all that start later
- $(video).on 'seeked', ->
- time = video.currentTime
- metaIntoView(time)
- return
- # if the metadata cue changes, highlight all current media and scroll
- # them into view
- $(metaTrack).on 'cuechange', ->
- j = 0
- time = video.currentTime
- $('#metadata li').removeClass 'current'
- while j<@activeCues.length
- activeStart = @activeCues[j].startTime
- if metalink = document.getElementById('m-' + activeStart)
- $(metalink).show()
- $(metalink).addClass 'current'
- ++j
- currentLength = $('#metadata .current').length
- if currentLength > 0
- $('#metadata .current').get(length - 1).scrollIntoView()
- return
- return
-
- # after video metadata have been loaded, display chapters and metadata in the
- # interactive area
- # Originally (and more appropriately, according to the standards),
- # only the 'loadedmetadata' event was used. However, Firefox triggers this event to soon,
- # i.e. when the readyStates for chapters and elements are 1 (loading) instead of 2 (loaded)
- # for the events, see https://www.w3schools.com/jsref/event_oncanplay.asp
- initialChapters = true
- initialMetadata = true
- video.addEventListener 'loadedmetadata', ->
- if initialChapters and chaptersElement.readyState == 2
- displayChapters()
- initialChapters = false
- if initialMetadata and metadataElement.readyState == 2
- displayMetadata()
- initialMetadata = false
-
- video.addEventListener 'canplay', ->
- if initialChapters and chaptersElement.readyState == 2
- displayChapters()
- initialChapters = false
- if initialMetadata and metadataElement.readyState == 2
- displayMetadata()
- initialMetadata = false
- return
-
-$(document).on 'turbolinks:load', ->
- thymeContainer = document.getElementById('thyme-container')
- # no need for thyme if no thyme container on the page
- return if thymeContainer == null
- # Video
- video = document.getElementById('video')
- thyme = document.getElementById('thyme')
- # Buttons
- playButton = document.getElementById('play-pause')
- muteButton = document.getElementById('mute')
- iaButton = document.getElementById('ia-active')
- iaClose = document.getElementById('ia-close')
- fullScreenButton = document.getElementById('full-screen')
- plusTenButton = document.getElementById('plus-ten')
- minusTenButton = document.getElementById('minus-ten')
- nextChapterButton = document.getElementById('next-chapter')
- previousChapterButton = document.getElementById('previous-chapter')
- backButton = document.getElementById('back-button')
- # Sliders
- seekBar = document.getElementById('seek-bar')
- volumeBar = document.getElementById('volume-bar')
- # Selectors
- speedSelector = document.getElementById('speed')
- # Time
- currentTime = document.getElementById('current-time')
- maxTime = document.getElementById('max-time')
- # ControlBar
- videoControlBar = document.getElementById('video-controlBar')
-
- # resizes the thyme container to the window dimensions, taking into account
- # whether the interactive area is displayed or hidden
- resizeContainer = ->
- height = $(window).height()
- factor = if $('#caption').is(':hidden') then 1 else 1 / 0.82
- width = Math.floor((video.videoWidth * $(window).height() /
- video.videoHeight) * factor)
- if width > $(window).width()
- shrink = $(window).width() / width
- height = Math.floor(height * shrink)
- width = $(window).width()
- top = Math.floor(0.5*($(window).height() - height))
- left = Math.floor(0.5*($(window).width() - width))
- $('#thyme-container').css('height', height + 'px')
- $('#thyme-container').css('width', width + 'px')
- $('#thyme-container').css('top', top + 'px')
- $('#thyme-container').css('left', left + 'px')
- return
-
- # detect IE/edge and inform user that they are not suppported if necessary,
- # only use browser player
- if document.documentMode || /Edge/.test(navigator.userAgent)
- alert($('body').data('badbrowser'))
- $('#caption').hide()
- $('#video-controlBar').hide()
- video.style.width = '100%'
- video.controls = true
- document.body.style.backgroundColor = 'black'
- resizeContainer()
- window.onresize = resizeContainer
- return
-
- setupHypervideo()
-
- # on small mobile display, fall back to standard browser player
- mobileDisplay = ->
- $('#caption').hide()
- $('#video-controlBar').hide()
- video.controls = true
- video.style.width = '100%'
- return
-
- # on large display, use anything thyme has to offer, disable native player
- largeDisplay = ->
- video.controls = false
- $('#caption').show()
- $('#video-controlBar').show()
- video.style.width = '82%'
- # directly closes the IA again, if the IA-button status is "-"
- if iaButton.dataset.status == 'false'
- iaButton.innerHTML = 'remove_from_queue'
- $('#caption').hide()
- video.style.width = '100%'
- $('#video-controlBar').css('width', '100%')
- $(window).trigger('resize')
- return
-
- # display native control bar if screen is very small
- if window.matchMedia("screen and (max-width: 767px)").matches
- mobileDisplay()
-
- if window.matchMedia("screen and (max-device-width: 767px)").matches
- mobileDisplay()
-
- # mediaQuery listener for very small screens
- match_verysmall = window.matchMedia("screen and (max-width: 767px)")
- match_verysmall.addListener (result) ->
- if result.matches
- mobileDisplay()
- return
-
- match_verysmalldevice = window.matchMedia("screen and (max-device-width: 767px)")
- match_verysmalldevice.addListener (result) ->
- if result.matches
- mobileDisplay()
- return
-
- # mediaQuery listener for normal screens
- match_normal = window.matchMedia("screen and (min-width: 768px)")
- match_normal.addListener (result) ->
- if result.matches
- largeDisplay()
- return
-
- match_normal = window.matchMedia("screen and (min-device-width: 768px)")
- match_normal.addListener (result) ->
- if result.matches
- largeDisplay()
- return
-
- window.onresize = resizeContainer
- video.onloadedmetadata = resizeContainer
-
- idleHideControlBar()
-
- # if mouse is moved or screen is toiched, show control bar
- video.addEventListener 'mouseover', showControlBar, false
- video.addEventListener 'mousemove', showControlBar, false
- video.addEventListener 'touchstart', showControlBar, false
-
- # Event listener for the play/pause button
- playButton.addEventListener 'click', ->
- if video.paused == true
- video.play()
- else
- video.pause()
- return
-
- video.onplay = ->
- playButton.innerHTML = 'pause'
-
- video.onpause = ->
- playButton.innerHTML = 'play_arrow'
-
- # Event listener for the mute button
- muteButton.addEventListener 'click', ->
- if video.muted == false
- video.muted = true
- muteButton.innerHTML = 'volume_off'
- else
- video.muted = false
- muteButton.innerHTML = 'volume_up'
- return
-
- # Event handler for the plusTen button
- plusTenButton.addEventListener 'click', ->
- video.currentTime = Math.min(video.currentTime + 10, video.duration)
- return
-
- # Event handler for the minusTen button
- minusTenButton.addEventListener 'click', ->
- video.currentTime = Math.max(video.currentTime - 10, 0)
- return
-
- # Event handler for the nextChapter button
- nextChapterButton.addEventListener 'click', ->
- next = nextChapterStart(video.currentTime)
- video.currentTime = nextChapterStart(video.currentTime) if next?
- return
-
- # Event handler for the previousChapter button
- previousChapterButton.addEventListener 'click', ->
- previous = previousChapterStart(video.currentTime)
- video.currentTime = previousChapterStart(video.currentTime) if previous?
- return
-
- # Event handler for speed speed selector
- speedSelector.addEventListener 'change', ->
- if video.preservesPitch?
- video.preservesPitch = true
- else if video.mozPreservesPitch?
- video.mozPreservesPitch = true
- else if video.webkitPreservesPitch?
- video.webkitPreservesPitch = true
- video.playbackRate = @options[@selectedIndex].value
- return
-
- # Event handler for interactive area activation button
- iaButton.addEventListener 'click', ->
- if iaButton.dataset.status == 'true'
- iaButton.innerHTML = 'remove_from_queue'
- iaButton.dataset.status = 'false'
- $('#caption').hide()
- video.style.width = '100%'
- $('#video-controlBar').css('width', '100%')
- $(window).trigger('resize')
- else
- iaButton.innerHTML = 'add_to_queue'
- iaButton.dataset.status = 'true'
- video.style.width = '82%'
- $('#video-controlBar').css('width', '82%')
- $('#caption').show()
- $(window).trigger('resize')
- return
-
- # Event Handler for Back Button
- backButton.addEventListener 'click', ->
- video.currentTime = this.dataset.time
- $(backButton).hide()
- $('#back-reference').hide()
- return
-
- # Event handler for close interactive area button
- iaClose.addEventListener 'click', ->
- $(iaButton).trigger('click')
- return
-
- # Event listener for the full-screen button
- # unfortunately, lots of brwoser specific code
- fullScreenButton.addEventListener 'click', ->
- if fullScreenButton.dataset.status == 'true'
- if document.exitFullscreen
- document.exitFullscreen()
- else if document.mozCancelFullScreen
- document.mozCancelFullScreen()
- else if document.webkitExitFullscreen
- document.webkitExitFullscreen()
- else
- if thymeContainer.requestFullscreen
- thymeContainer.requestFullscreen()
- else if thymeContainer.mozRequestFullScreen
- thymeContainer.mozRequestFullScreen()
- else if thymeContainer.webkitRequestFullscreen
- thymeContainer.webkitRequestFullscreen()
- return
-
- document.onfullscreenchange = ->
- if document.fullscreenElement != null
- fullScreenButton.innerHTML = 'fullscreen_exit'
- fullScreenButton.dataset.status = 'true'
- else
- fullScreenButton.innerHTML = 'fullscreen'
- fullScreenButton.dataset.status = 'false'
- # brute force patch: apparently, after exiting fullscreen mode,
- # window.onresize is triggered twice(!), the second time with incorrect
- # window height data, which results in a video area not quite filling
- # the whole window. The next line resizes the container again.
- setTimeout(resizeContainer, 20)
- return
-
- document.onwebkitfullscreenchange = ->
- if document.webkitFullscreenElement != null
- fullScreenButton.innerHTML = 'fullscreen_exit'
- fullScreenButton.dataset.status = 'true'
- else
- fullScreenButton.innerHTML = 'fullscreen'
- fullScreenButton.dataset.status = 'false'
- setTimeout(resizeContainer, 20)
- return
-
- document.onmozfullscreenchange = ->
- if document.mozFullScreenElement != null
- fullScreenButton.innerHTML = 'fullscreen_exit'
- fullScreenButton.dataset.status = 'true'
- else
- fullScreenButton.innerHTML = 'fullscreen'
- fullScreenButton.dataset.status = 'false'
- setTimeout(resizeContainer, 20)
- return
-
- # Event listeners for the seek bar
- seekBar.addEventListener 'input', ->
- time = video.duration * seekBar.value / 100
- video.currentTime = time
- return
-
- # if mouse is moved over seek bar, display tooltip with current chapter
- seekBar.addEventListener 'mousemove', (evt) ->
- positionInfo = seekBar.getBoundingClientRect()
- width = positionInfo.width;
- left = positionInfo.left
- measuredSeconds = ((evt.pageX - left)/width) * video.duration
- seconds = Math.min(measuredSeconds, video.duration)
- seconds = Math.max(seconds, 0)
- previous = previousChapterStart(seconds)
- info = $('#c-' + $.escapeSelector(previous)).text().split(':')[0]
- seekBar.setAttribute('title', info)
- return
-
- # if videomedtadata have been loaded, set up video length, volume bar and
- # seek bar
- video.addEventListener 'loadedmetadata', ->
- maxTime.innerHTML = secondsToTime(video.duration)
- volumeBar.value = video.volume
- volumeBar.style.backgroundImage = 'linear-gradient(to right,' +
- ' #2497E3, #2497E3 ' + video.volume*100 + '%, #ffffff ' +
- video.volume*100 + '%, #ffffff)'
- if video.dataset.time?
- time = video.dataset.time
- video.currentTime = time
- seekBar.value = video.currentTime / video.duration * 100
- else
- seekBar.value = 0
- return
-
- # Update the seek bar as the video plays
- # uses a gradient for seekbar video time visualization
- video.addEventListener 'timeupdate', ->
- value = 100 / video.duration * video.currentTime
- seekBar.value = value
- seekBar.style.backgroundImage = 'linear-gradient(to right,' +
- ' #2497E3, #2497E3 ' + value + '%, #ffffff ' + value + '%, #ffffff)'
- currentTime.innerHTML = secondsToTime(video.currentTime)
- return
-
- # Pause the video when the seek handle is being dragged
- seekBar.addEventListener 'mousedown', ->
- video.dataset.paused = video.paused
- video.pause()
- return
-
- # Play the video when the seek handle is dropped
- seekBar.addEventListener 'mouseup', ->
- video.play() unless video.dataset.paused == 'true'
- return
-
- # Event listener for the volume bar
- volumeBar.addEventListener 'input', ->
- value = volumeBar.value
- video.volume = value
- return
-
- video.addEventListener 'volumechange', ->
- value = video.volume
- volumeBar.value = value
- volumeBar.style.backgroundImage = 'linear-gradient(to right,' +
- ' #2497E3, #2497E3 ' + value*100 + '%, #ffffff ' + value*100 + '%, #ffffff)'
- return
-
- video.addEventListener 'click', ->
- if video.paused == true
- video.play()
- else
- video.pause()
- showControlBar()
- return
-
- # thyme can be used by keyboard as well
- # Arrow up - next chapter
- # Arrow down - previous chapter
- # Arrow right - plus ten seconds
- # Arrow left - minus ten seconds
- # f - fullscreen
- # Page up - volume up
- # Page down - volume down
- # m - mute
- # i - toggle interactive area
- window.addEventListener 'keydown', (evt) ->
- key = evt.key
- if key == ' '
- if video.paused == true
- video.play()
- else
- video.pause()
- else if key == 'ArrowUp'
- $(nextChapterButton).trigger('click')
- else if key == 'ArrowDown'
- $(previousChapterButton).trigger('click')
- else if key == 'ArrowRight'
- $(plusTenButton).trigger('click')
- else if key == 'ArrowLeft'
- $(minusTenButton).trigger('click')
- else if key == 'f'
- $(fullScreenButton).trigger('click')
- else if key == 'PageUp'
- video.volume = Math.min(video.volume + 0.1, 1)
- else if key == 'PageDown'
- video.volume = Math.max(video.volume - 0.1, 0)
- else if key == 'm'
- $(muteButton).trigger('click')
- else if key == 'i'
- $(iaButton).trigger('click')
- return
- return
diff --git a/app/assets/javascripts/thyme/annotations/annotation.js b/app/assets/javascripts/thyme/annotations/annotation.js
new file mode 100644
index 000000000..4b3d4d432
--- /dev/null
+++ b/app/assets/javascripts/thyme/annotations/annotation.js
@@ -0,0 +1,104 @@
+/**
+ This class helps to represent an annotation in JavaScript.
+*/
+// eslint-disable-next-line no-unused-vars
+class Annotation {
+ constructor(json) {
+ // We only save attributes that are needed in the thyme related JavaScripts!
+ this.category = Category.getByName(json.category);
+ this.color = json.color;
+ this.comment = json.comment;
+ this.id = json.id;
+ this.seconds = thymeUtility.timestampToSeconds(json.timestamp);
+ this.subcategory = Subcategory.getByName(json.subcategory);
+ this.belongsToCurrentUser = json.belongs_to_current_user;
+ }
+
+ /*
+ * AUXILIARY METHODS
+ */
+
+ /*
+ Create normal marker.
+
+ color = Color of the marker.
+ strokeColor = Color of the border of the marker.
+ onClick = A function triggered when one clicks on the marker.
+ */
+ createMarker(color, strokeColor, onClick) {
+ this.#create(color, false, onClick);
+ }
+
+ /*
+ Create big marker with customizable border color.
+ (Used e.g. for big mistake markers in the feedback player.)
+
+ color = Color of the marker.
+ strokeColor = Color of the border of the marker.
+ onClick = A function triggered when one clicks on the marker.
+ */
+ createBigMarker(color, strokeColor, onClick) {
+ this.#create(color, true, onClick);
+ }
+
+ /*
+ An auxiliary method, only used for a better structure of createMarker() and createBigMarker()!
+ */
+ #create(color, isBigMarker, onClick) {
+ const markerStr = `
+
+ `;
+ $("#" + thymeAttributes.markerBarId).append(markerStr);
+
+ const marker = $("#marker-" + this.id);
+ const size = thymeAttributes.seekBar.element.clientWidth - 15;
+ const ratio = this.seconds / thymeAttributes.video.duration;
+ const offset = marker.parent().offset().left + ratio * size + 3;
+ marker.offset({ left: offset });
+
+ marker.on("click", function () {
+ thymeAttributes.disableAnnotationKeyListeners = false;
+ onClick();
+ });
+ }
+
+ /*
+ Returns a string with the correct translation of the category and subcategory of this annotation.
+ */
+ categoryLocale() {
+ const c = this.category;
+ const s = this.subcategory;
+ return s ? c.locale() + " (" + s.locale() + ")" : c.locale();
+ }
+
+ /*
+ * Returns true if the given annotation is the last annotation
+ * in thymeAttributes.annotations
+ */
+ isFirst() {
+ return this == thymeAttributes.annotations[0];
+ }
+
+ /*
+ * Returns true if the given annotation is the last annotation
+ * in thymeAttributes.annotations
+ */
+ isLast() {
+ return this == thymeAttributes.annotations[thymeAttributes.annotations.length - 1];
+ }
+
+ updateOpenAnnotationMarker(oldId, newId) {
+ if (oldId) {
+ const oldMarker = $("#marker-" + oldId).children("i");
+ oldMarker.removeClass("annotation-marker-shown");
+ }
+
+ const newMarker = $("#marker-" + newId).children("i");
+ newMarker.addClass("annotation-marker-shown");
+ }
+
+ markCurrentAnnotationAsNotShown() {
+ const marker = $("#marker-" + this.id).children("i");
+ marker.removeClass("annotation-marker-shown");
+ }
+}
diff --git a/app/assets/javascripts/thyme/annotations/annotation_area.js b/app/assets/javascripts/thyme/annotations/annotation_area.js
new file mode 100644
index 000000000..a1ddc3653
--- /dev/null
+++ b/app/assets/javascripts/thyme/annotations/annotation_area.js
@@ -0,0 +1,238 @@
+/**
+ This class helps to represent the annotation area in java script.
+*/
+// eslint-disable-next-line no-unused-vars
+class AnnotationArea {
+ static DISABLED_BUTTON_OPACITY = 0.2;
+
+ /*
+ hasFancyStyle = If true, all buttons are shown, if false,
+ only previous, goto and next are shown.
+
+ colorFunc = A function for colorizing the annotation area
+ which takes an annotation as argument and gives
+ back a color.
+
+ onClose = A function that is executed when closing the
+ annotation area.
+
+ isValid = A function which takes an annotation as argument
+ and returns true, if and only if the annotation is
+ "valid", i.e. should be visualized in the annotation
+ area. (This is needed to skip unwanted annotations in
+ the previous/next button listeners.)
+ */
+ constructor(hasFancyStyle, colorFunc, onClose, isValid) {
+ this.isActive = false;
+ this.annotation = null; // the current annotation
+ this.colorFunc = colorFunc;
+ this.onClose = onClose;
+ this.isValid = isValid;
+ this.hasFancyStyle = hasFancyStyle;
+
+ this.caption = $("#annotation-caption");
+ this.infoBar = $("#annotation-infobar");
+ this.commentField = $("#annotation-comment");
+ this.previousButton = $("#annotation-previous-button");
+ this.gotoButton = $("#annotation-goto-button");
+ this.editButton = $("#annotation-edit-button");
+ this.closeButton = $("#annotation-close-button");
+ this.nextButton = $("#annotation-next-button");
+ this.areaButtonsRegion = $("#annotation-area-buttons");
+
+ this.localesId = "annotation-locales";
+
+ if (!hasFancyStyle) {
+ this.editButton.hide();
+ this.closeButton.hide();
+ }
+ }
+
+ /*
+ Show the annotation area.
+ */
+ show() {
+ this.caption.show();
+ this.isActive = true;
+ }
+
+ /*
+ Hide the annotation area.
+ */
+ hide() {
+ this.caption.hide();
+ this.isActive = false;
+ }
+
+ /*
+ Update the annotation area with the content of the given annotation.
+ */
+ update(annotation) {
+ if (!annotation) {
+ return;
+ }
+ const oldId = this.annotation ? this.annotation.id : null;
+
+ this.annotation = annotation;
+
+ // update info and comment field
+ this.#updateInfoAndCommentField(annotation, this.colorFunc(annotation));
+ // update buttons
+ this.#updatePreviousButton(annotation);
+ this.#updateNextButton(annotation);
+ this.#updateGotoButton(annotation);
+ if (this.hasFancyStyle) {
+ this.#updateEditButton(annotation);
+ this.#updateCloseButton();
+ }
+ annotation.updateOpenAnnotationMarker(oldId, annotation.id);
+
+ // render LaTex
+ const commentId = this.commentField.attr("id");
+ thymeUtility.renderLatex(document.getElementById(commentId));
+ }
+
+ showAnnotationWithId(id) {
+ const annotation = AnnotationManager.find(id);
+ if (annotation) {
+ thymeAttributes.annotationManager.forceTriggerOnClick(annotation);
+ }
+ }
+
+ /*
+ AUXILIARY METHODS
+ */
+ #updateInfoAndCommentField(annotation, color) {
+ const head = annotation.categoryLocale();
+ const comment = annotation.comment.replaceAll("\n", " ");
+ const headColor = thymeUtility.lightenUp(color, 2);
+ const backgroundColor = thymeUtility.lightenUp(color, 3);
+ this.infoBar.empty().append(head);
+ this.infoBar.css("background-color", headColor);
+ this.commentField.empty().append(comment);
+
+ // Comment field background gradient
+ const colorGradientEnd = thymeUtility.lightenUp(color, 2.5);
+ const gradient = `linear-gradient(to bottom, ${backgroundColor} 50%, ${colorGradientEnd} 100%)`;
+ this.caption.css("background-image", gradient);
+
+ // Area buttons
+ this.areaButtonsRegion.css("background-color", headColor);
+ }
+
+ #updatePreviousButton(annotation) {
+ const area = this; // need a reference inside the listener scope!
+ this.previousButton.off("click");
+ this.previousButton.on("click", function () {
+ area.update(area.previousValidAnnotation());
+ });
+ if (annotation.isFirst()) {
+ this.previousButton.css("opacity", AnnotationArea.DISABLED_BUTTON_OPACITY);
+ }
+ else {
+ this.previousButton.css("opacity", 1);
+ }
+ }
+
+ #updateNextButton(annotation) {
+ const area = this; // need a reference inside the listener scope!
+ this.nextButton.off("click");
+ this.nextButton.on("click", function () {
+ area.update(area.nextValidAnnotation());
+ });
+ if (annotation.isLast()) {
+ this.nextButton.css("opacity", AnnotationArea.DISABLED_BUTTON_OPACITY);
+ }
+ else {
+ this.nextButton.css("opacity", 1);
+ }
+ }
+
+ #updateGotoButton(annotation) {
+ this.gotoButton.off("click");
+ this.gotoButton.on("click", function () {
+ video.currentTime = annotation.seconds;
+ });
+ }
+
+ #updateEditButton(annotation) {
+ const localesId = this.localesId;
+ this.editButton.off("click");
+ this.editButton.on("click", function () {
+ thymeAttributes.video.pause();
+ thymeAttributes.lockKeyListeners = true;
+ $.ajax(Routes.edit_annotation_path(annotation.id), {
+ type: "GET",
+ dataType: "script",
+ data: {
+ annotation_id: annotation.id,
+ },
+ success: function (permitted) {
+ if (permitted === "false") {
+ alert(document.getElementById(localesId).dataset.permission);
+ }
+ },
+ error: function (e) {
+ console.log(e);
+ },
+ });
+ });
+ }
+
+ unmarkCurrentAnnotationAsShown() {
+ if (!this.annotation) {
+ return;
+ }
+ this.annotation.markCurrentAnnotationAsNotShown();
+ }
+
+ #updateCloseButton() {
+ const area = this; // need a reference inside the listener scope!
+ this.closeButton.off("click");
+ this.closeButton.on("click", function () {
+ area.unmarkCurrentAnnotationAsShown();
+ area.annotation = undefined;
+ area.hide();
+ thymeAttributes.disableAnnotationKeyListeners = true;
+ if (area.onClose != null) {
+ area.onClose();
+ }
+ });
+ }
+
+ /*
+ Returns the first annotation which is valid and which comes
+ before the input annotation on the timeline.
+ Returns null if no valid annotation before the input annotation
+ exists.
+ */
+ previousValidAnnotation() {
+ const currentId = this.annotation.id;
+ const currentIndex = AnnotationManager.findIndex(currentId);
+ const annotations = thymeAttributes.annotations;
+ for (let i = currentIndex - 1; i >= 0; i--) {
+ if (this.isValid(annotations[i])) {
+ return annotations[i];
+ }
+ }
+ return null;
+ }
+
+ /*
+ Returns the first annotation which is valid and which comes
+ after the input annotation on the timeline.
+ Returns null if no valid annotation after the input annotation
+ exists.
+ */
+ nextValidAnnotation() {
+ const currentId = this.annotation.id;
+ const currentIndex = AnnotationManager.findIndex(currentId);
+ const annotations = thymeAttributes.annotations;
+ for (let i = currentIndex + 1; i < annotations.length; i++) {
+ if (this.isValid(annotations[i])) {
+ return annotations[i];
+ }
+ }
+ return null;
+ }
+}
diff --git a/app/assets/javascripts/thyme/annotations/annotation_manager.js b/app/assets/javascripts/thyme/annotations/annotation_manager.js
new file mode 100644
index 000000000..aed7d7c3e
--- /dev/null
+++ b/app/assets/javascripts/thyme/annotations/annotation_manager.js
@@ -0,0 +1,170 @@
+/**
+ * This class provides methods that help to manage all annotations in a thyme player.
+ */
+// eslint-disable-next-line no-unused-vars
+class AnnotationManager {
+ /*
+ colorFunc = A function which takes an annotation and gives
+ back a color for the corresponding marker.
+
+ strokeColorFunc = A function which takes an annotation and gives
+ back a color for the stroke of the corresponding marker.
+
+ sizeFunc = A function which takes an annotation and returns
+ a boolean that is true if and only if the marker
+ corresponding to the annotation should be big.
+
+ onClick = A function that takes an annotation as argument and
+ which is triggered when the corresponding marker is
+ clicked.
+
+ onUpdate = A function that is triggered when the annotations
+ have been updated.
+
+ isValid = A function that takes an annotation as argument and
+ returns a boolean. If and only if it returns true,
+ the given annotation is viualized by this annotation
+ manager.
+ */
+ constructor(colorFunc, strokeColorFunc, sizeFunc, onClick, onUpdate, isValid) {
+ this.colorFunc = colorFunc;
+ this.strokeColorFunc = strokeColorFunc;
+ this.sizeFunc = sizeFunc;
+ this.onClick = onClick;
+ this.onUpdate = onUpdate;
+ this.isValid = isValid;
+ this.isDbCalledForFreshAnnotations = false;
+ }
+
+ forceTriggerOnClick(annotation) {
+ this.onClick(annotation);
+ }
+
+ /*
+ Updates the markers on the timeline, i.e. the visual represention of the annotations.
+ This method is e.g. used for rearranging the markers when the window is being resized.
+ Don't mix up with updateAnnotatons() which sends an AJAX request and checks for changes
+ in the database.
+ */
+ updateMarkers() {
+ // In case the annotations have not been loaded yet, do nothing.
+ // This situation might occur during the initial page load.
+ if (thymeAttributes.annotations === null) {
+ if (!this.isDbCalledForFreshAnnotations) {
+ this.updateAnnotations();
+ }
+ return;
+ }
+
+ const annotationManager = this;
+ $("#" + thymeAttributes.markerBarId).empty();
+ AnnotationManager.sortAnnotations();
+
+ for (const a of thymeAttributes.annotations) {
+ if (this.isValid(a)) {
+ function onClick() {
+ annotationManager.onClick(a);
+ }
+ if (this.sizeFunc && this.sizeFunc(a)) {
+ a.createBigMarker(this.colorFunc(a), this.strokeColorFunc(a), onClick);
+ }
+ else {
+ a.createMarker(this.colorFunc(a), this.strokeColorFunc(a), onClick);
+ }
+ }
+ }
+ // call additional function that is individual for each player
+ this.onUpdate();
+ }
+
+ /*
+ Sends a AJAX request which returns all the annotations for the given medium.
+ This method is e.g. used when a new annotation is being created.
+ Don't mix up with updateMarkers() which just updates the position of the markers!
+
+ onSucess = A function that is triggered when the annotations have been
+ successfully updated.
+ onSuccess = A function that is triggered when the annotations have been
+ successfully updated.
+ */
+ updateAnnotations(onSuccess) {
+ if (!thymeAttributes.annotationFeatureActive) {
+ return;
+ }
+
+ this.isDbCalledForFreshAnnotations = true; // Lock resource
+
+ const manager = this;
+ $.ajax(Routes.update_annotations_path(), {
+ type: "GET",
+ dataType: "json",
+ data: {
+ mediumId: thymeAttributes.mediumId,
+ },
+ success: (annotations) => {
+ // update the annotation field in thymeAttributes
+ thymeAttributes.annotations = [];
+ if (!annotations) {
+ return;
+ }
+ for (let a of annotations) {
+ thymeAttributes.annotations.push(new Annotation(a));
+ }
+ // update visual representation on the seek bar
+ manager.updateMarkers();
+
+ if (onSuccess) {
+ onSuccess();
+ }
+ },
+ always: () => {
+ // Free resource
+ manager.isDbCalledForFreshAnnotations = false;
+ },
+ });
+ }
+
+ /* sorts all annotations according to their timestamp */
+ static sortAnnotations() {
+ if (!thymeAttributes.annotations) {
+ return;
+ }
+ thymeAttributes.annotations.sort(function (ann1, ann2) {
+ return ann1.seconds - ann2.seconds;
+ });
+ }
+
+ /*
+ Finds the annotation with the given ID in thymeAttributes.annotations.
+ Returns null if it doesn't exist.
+ */
+ static find(id) {
+ const annotations = thymeAttributes.annotations;
+ if (!annotations) {
+ return null;
+ }
+ for (let a of annotations) {
+ if (a.id === id) {
+ return a;
+ }
+ }
+ return null;
+ }
+
+ /*
+ Finds the index in the array thymeAttributes.annotations of an annotation
+ with the given id. Returns undefined if the array doesn't contain this annotation.
+ */
+ static findIndex(id) {
+ const annotations = thymeAttributes.annotations;
+ if (!annotations) {
+ return undefined;
+ }
+ for (let i = 0; i < annotations.length; i++) {
+ if (annotations[i].id === id) {
+ return i;
+ }
+ }
+ return undefined;
+ }
+}
diff --git a/app/assets/javascripts/thyme/annotations/category.js b/app/assets/javascripts/thyme/annotations/category.js
new file mode 100644
index 000000000..56d0aed9a
--- /dev/null
+++ b/app/assets/javascripts/thyme/annotations/category.js
@@ -0,0 +1,23 @@
+// eslint-disable-next-line no-unused-vars
+class Category extends CategoryEnum {
+ static _categories = [];
+
+ static NOTE = new Category("note", "#f78f19");
+ static CONTENT = new Category("content", "#A333C8");
+ static PRESENTATION = new Category("presentation", "#2185D0");
+ static MISTAKE = new Category("mistake", "#fc1461");
+
+ constructor(name, color) {
+ super(name);
+ this.color = color;
+ Category._categories.push(this);
+ }
+
+ static getByName(name) {
+ return super.getByName(name, Category._categories);
+ }
+
+ static all() {
+ return super.all(Category._categories);
+ }
+}
diff --git a/app/assets/javascripts/thyme/annotations/category_enum.js b/app/assets/javascripts/thyme/annotations/category_enum.js
new file mode 100644
index 000000000..f687ee424
--- /dev/null
+++ b/app/assets/javascripts/thyme/annotations/category_enum.js
@@ -0,0 +1,39 @@
+// eslint-disable-next-line no-unused-vars
+class CategoryEnum {
+ constructor(name) {
+ this.name = name;
+ }
+
+ /*
+ * Returns the correct locale for the given category.
+ * This method will only work if the given thyme player
+ * has a div-tag with the id "annotation-locales" which
+ * includes the name of the categories as data sets, e.g.
+ * data-note="<%= t(...) %>".
+ */
+ locale() {
+ return document.getElementById("annotation-locales").dataset[this.name];
+ }
+
+ /*
+ * Return the object with the given name in the given array.
+ *
+ * Override in subclasses.
+ */
+ static getByName(name, array) {
+ for (let a of array) {
+ if (a.name === name) {
+ return a;
+ }
+ }
+ }
+
+ /*
+ * Returns an array with all objects of this enum.
+ *
+ * Override in subclasses.
+ */
+ static all(array) {
+ return array.slice();
+ }
+}
diff --git a/app/assets/javascripts/thyme/annotations/subcategory.js b/app/assets/javascripts/thyme/annotations/subcategory.js
new file mode 100644
index 000000000..1ab193887
--- /dev/null
+++ b/app/assets/javascripts/thyme/annotations/subcategory.js
@@ -0,0 +1,21 @@
+// eslint-disable-next-line no-unused-vars
+class Subcategory extends CategoryEnum {
+ static _subcategories = []; // do not manipulate this array outside of this class!
+
+ static DEFINITION = new Subcategory("definition");
+ static ARGUMENT = new Subcategory("argument");
+ static STRATEGY = new Subcategory("strategy");
+
+ constructor(name) {
+ super(name);
+ Subcategory._subcategories.push(this);
+ }
+
+ static getByName(name) {
+ return super.getByName(name, Subcategory._subcategories);
+ }
+
+ static all() {
+ return super.all(Subcategory._subcategories);
+ }
+}
diff --git a/app/assets/javascripts/thyme/attributes.js b/app/assets/javascripts/thyme/attributes.js
new file mode 100644
index 000000000..5fc1415c9
--- /dev/null
+++ b/app/assets/javascripts/thyme/attributes.js
@@ -0,0 +1,64 @@
+/**
+ This file wraps up some attributes that are used in the different
+ versions of the thyme player.
+
+ Most attributes are set to undefined or null and must be
+ defined when the player is loaded.
+*/
+// eslint-disable-next-line no-unused-vars
+const thymeAttributes = {
+
+ /* Saves a reference on the annotation area. */
+ annotationArea: null,
+
+ /* Use this to check if the annotation feature is activated
+ (which it is not for users who aren't signed in). */
+ annotationFeatureActive: false,
+
+ /* When callig the updateMarkers() method this will be used to save an
+ array containing all annotations. */
+ annotations: null,
+
+ /* Saves a reference on the annotation manager */
+ annotationManager: null,
+
+ /* A list with all the chapters of the current video. */
+ chapters: null,
+
+ /* Saves a reference on the chapter manager. */
+ chapterManager: null,
+
+ /* If the window width (in px) gets below this threshold value, hide the control bar
+ (default value). */
+ hideControlBarThreshold: {
+ x: 850,
+ y: 500,
+ },
+
+ /* Saves a reference on the interactive area. */
+ interactiveArea: null,
+
+ /* A boolean that helps to deactivate all key listeners
+ for the time the annotation modal opens and the user
+ has to write text into the command box. */
+ lockKeyListeners: false,
+
+ disableAnnotationKeyListeners: false,
+
+ /* Saves the ID of the HTML element to which annotations are appended. */
+ markerBarId: undefined,
+
+ /* When loading a player, it should save the medium id in this field for later use
+ in different files. */
+ mediumId: undefined,
+
+ /* Saves a reference on the metadata manager. */
+ metadataManager: null,
+
+ /* Saves a reference on the video's seek bar. */
+ seekBar: undefined,
+
+ /* Saves a reference on the video itself */
+ video: undefined,
+
+};
diff --git a/app/assets/javascripts/thyme/chapter_manager.js b/app/assets/javascripts/thyme/chapter_manager.js
new file mode 100644
index 000000000..72e3f266a
--- /dev/null
+++ b/app/assets/javascripts/thyme/chapter_manager.js
@@ -0,0 +1,121 @@
+/**
+ This file wraps up most functionality of the thyme player(s) concerning chapters.
+*/
+// eslint-disable-next-line no-unused-vars
+class ChapterManager {
+ constructor(chapterListId, iaBackButton) {
+ this.chapterListId = chapterListId;
+ this.iaBackButton = iaBackButton;
+ }
+
+ load() {
+ let initialChapters = true;
+ const videoId = thymeAttributes.video.id;
+ const chaptersElement = $("#" + videoId + ' track[kind="chapters"]').get(0);
+ const chapterManager = this;
+
+ /* after video metadata have been loaded, display chapters in the interactive area
+ Originally (and more appropriately, according to the standards),
+ only the 'loadedmetadata' event was used. However, Firefox triggers this event too soon,
+ i.e. when the readyStates for chapters and elements are 1 (loading) instead of 2 (loaded)
+ for the events, see https://www.w3schools.com/jsref/event_oncanplay.asp */
+ video.addEventListener("loadedmetadata", function () {
+ if (initialChapters && chaptersElement.readyState === 2) {
+ chapterManager.#displayChapters();
+ initialChapters = false;
+ }
+ });
+ video.addEventListener("canplay", function () {
+ if (initialChapters && chaptersElement.readyState === 2) {
+ chapterManager.#displayChapters();
+ initialChapters = false;
+ }
+ });
+ }
+
+ previousChapterStart() {
+ const currentTime = thymeAttributes.video.currentTime;
+ /* NOTE: We cannot use times as an attribute (yet) because it's initialized
+ before the dataset times is loaded into the HTML. */
+ const times = JSON.parse(document.getElementById(this.chapterListId).dataset.times);
+ if (times.length === 0) {
+ return;
+ }
+ for (let i = times.length - 1; i >= 0; i--) {
+ if (times[i] < currentTime) {
+ if (currentTime - times[i] > 3) {
+ return times[i];
+ }
+ else if (i > 0) {
+ return times[i - 1];
+ }
+ }
+ }
+ }
+
+ nextChapterStart() {
+ const currentTime = thymeAttributes.video.currentTime;
+ const times = JSON.parse(document.getElementById(this.chapterListId).dataset.times);
+ if (times.length === 0) {
+ return;
+ }
+ for (let i = 0; i < times.length; i++) {
+ if (times[i] > currentTime) {
+ return times[i];
+ }
+ }
+ }
+
+ #displayChapters() {
+ const videoId = thymeAttributes.video.id;
+ const chapterListId = this.chapterListId;
+ const iaBackButton = this.iaBackButton;
+ const chapterList = $("#" + chapterListId);
+ const chaptersElement = $("#" + videoId + ' track[kind="chapters"]').get(0);
+
+ let chaptersTrack;
+ if (chaptersElement.readyState === 2 && (chaptersTrack = chaptersElement.track)) {
+ chaptersTrack.mode = "hidden";
+ let times = [];
+ // read out the chapter track cues and generate html elements for chapters,
+ // run katex on them
+ for (let i = 0; i < chaptersTrack.cues.length; i++) {
+ const cue = chaptersTrack.cues[i];
+ const chapterName = cue.text;
+ const start = cue.startTime;
+ times.push(start);
+ const $listItem = $(" ");
+ const $link = $(" ", {
+ id: "c-" + start,
+ text: chapterName,
+ });
+ chapterList.append($listItem.append($link));
+ const chapterElement = $link.get(0);
+ thymeUtility.renderLatex(chapterElement);
+ $link.data("text", chapterName);
+ // if a chapter element is clicked, transport to chapter start time
+ $link.on("click", function () {
+ iaBackButton.update();
+ video.currentTime = this.id.replace("c-", "");
+ });
+ }
+ // store start times as data attribute
+ chapterList.get(0).dataset.times = JSON.stringify(times);
+ chapterList.show();
+ // if the chapters cue changes (i.e. a switch between chapters), highlight
+ // current chapter elment and scroll it into view, remove highlighting from
+ // old chapter
+ $(chaptersTrack).on("cuechange", function () {
+ $("#" + chapterListId + " li a").removeClass("current");
+ if (this.activeCues.length > 0) {
+ const activeStart = this.activeCues[0].startTime;
+ const chapter = document.getElementById("c-" + activeStart);
+ if (chapter) {
+ $(chapter).addClass("current");
+ chapter.scrollIntoView();
+ }
+ }
+ });
+ }
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/add_item_button.js b/app/assets/javascripts/thyme/components/add_item_button.js
new file mode 100644
index 000000000..9a3a0cf79
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/add_item_button.js
@@ -0,0 +1,23 @@
+// eslint-disable-next-line no-unused-vars
+class AddItemButton extends Component {
+ add() {
+ const video = thymeAttributes.video;
+
+ // Event listener for addItem button
+ this.element.addEventListener("click", function () {
+ video.pause();
+ // round time down to three decimal digits
+ const time = video.currentTime;
+ const intTime = Math.floor(time);
+ const roundTime = intTime + Math.floor((time - intTime) * 1000) / 1000;
+ video.currentTime = roundTime;
+ $.ajax(Routes.add_item_path(thymeAttributes.mediumId), {
+ type: "GET",
+ dataType: "script",
+ data: {
+ time: video.currentTime,
+ },
+ });
+ });
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/add_reference_button.js b/app/assets/javascripts/thyme/components/add_reference_button.js
new file mode 100644
index 000000000..9d2fe7c32
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/add_reference_button.js
@@ -0,0 +1,23 @@
+// eslint-disable-next-line no-unused-vars
+class AddReferenceButton extends Component {
+ add() {
+ const video = thymeAttributes.video;
+
+ // Event listener for addItem button
+ this.element.addEventListener("click", function () {
+ video.pause();
+ // round time down to three decimal digits
+ const time = video.currentTime;
+ const intTime = Math.floor(time);
+ const roundTime = intTime + Math.floor((time - intTime) * 1000) / 1000;
+ video.currentTime = roundTime;
+ $.ajax(Routes.add_reference_path(thymeAttributes.mediumId), {
+ type: "GET",
+ dataType: "script",
+ data: {
+ time: video.currentTime,
+ },
+ });
+ });
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/add_screenshot_button.js b/app/assets/javascripts/thyme/components/add_screenshot_button.js
new file mode 100644
index 000000000..dcf314100
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/add_screenshot_button.js
@@ -0,0 +1,34 @@
+// eslint-disable-next-line no-unused-vars
+class AddScreenshotButton extends Component {
+ constructor(element, canvasId) {
+ super(element);
+ this.canvas = document.getElementById(canvasId);
+ }
+
+ add() {
+ const video = thymeAttributes.video;
+ const canvas = this.canvas;
+
+ // Event listener for add screenshot button
+ this.element.addEventListener("click", function () {
+ video.pause();
+ // extract video screenshot from canvas
+ const context = canvas.getContext("2d");
+ context.drawImage(video, 0, 0, canvas.width, canvas.height);
+ const base64image = canvas.toDataURL("image/png");
+ // Get our file
+ const file = thymeUtility.dataURLtoBlob(base64image);
+ // Create new form data
+ const fd = new FormData();
+ // Append our Canvas image file to the form data
+ fd.append("image", file);
+ // And send it
+ $.ajax(Routes.add_screenshot_path(thymeAttributes.mediumId), {
+ type: "POST",
+ data: fd,
+ processData: false,
+ contentType: false,
+ });
+ });
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/annotation_button.js b/app/assets/javascripts/thyme/components/annotation_button.js
new file mode 100644
index 000000000..e81664b43
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/annotation_button.js
@@ -0,0 +1,20 @@
+// eslint-disable-next-line no-unused-vars
+class AnnotationButton extends Component {
+ add() {
+ const video = thymeAttributes.video;
+ const element = this.element;
+
+ // Event handler for the annotation button
+ element.addEventListener("click", function () {
+ video.pause();
+ $.ajax(Routes.new_annotation_path(), {
+ type: "GET",
+ dataType: "script",
+ data: {
+ total_seconds: video.currentTime,
+ medium_id: thymeAttributes.mediumId,
+ },
+ });
+ });
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/annotation_category_toggle.js b/app/assets/javascripts/thyme/components/annotation_category_toggle.js
new file mode 100644
index 000000000..2ee0c7cc9
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/annotation_category_toggle.js
@@ -0,0 +1,51 @@
+// eslint-disable-next-line no-unused-vars
+class AnnotationCategoryToggle extends Component {
+ /*
+ element = A reference on the HTML component (via document.getElementByID()).
+ category = The category which this toggle triggers.
+ heatmap = The heatmap that will be updated depending on the value of the toggle.
+ */
+ constructor(category, heatmap) {
+ const id = AnnotationCategoryToggle.categoryToElementId(category);
+ super(id);
+ this.category = category;
+ this.heatmap = heatmap;
+ }
+
+ static categoryToElementId(category) {
+ return `annotation-category-${category.name}-switch`;
+ }
+
+ add() {
+ const heatmap = this.heatmap;
+ if (heatmap) {
+ heatmap.addCategory(this.category); // add category when adding the button
+ }
+
+ const categoryToggle = this;
+
+ this.element.addEventListener("click", function () {
+ thymeAttributes.annotationManager.updateAnnotations();
+ if (!heatmap) {
+ return;
+ }
+
+ if (categoryToggle.isChecked()) {
+ heatmap.addCategory(this.category);
+ }
+ else {
+ heatmap.removeCategory(this.category);
+ }
+ heatmap.draw();
+ });
+ }
+
+ isChecked() {
+ return this.element.checked;
+ }
+
+ static isChecked(category) {
+ const id = AnnotationCategoryToggle.categoryToElementId(category);
+ return document.getElementById(id).checked;
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/annotations_toggle.js b/app/assets/javascripts/thyme/components/annotations_toggle.js
new file mode 100644
index 000000000..0e179fdaa
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/annotations_toggle.js
@@ -0,0 +1,68 @@
+// eslint-disable-next-line no-unused-vars
+class AnnotationsToggle extends Component {
+ constructor(element) {
+ super(element);
+ this.id = element;
+ this.check = document.getElementById(this.id + "-check");
+ this.$check = $("#" + this.id + "-check");
+ this.div = $("#" + this.id);
+ this.flag = false;
+ }
+
+ add() {
+ if (this.flag || !thymeAttributes.annotationFeatureActive) {
+ return;
+ }
+
+ this.flag = true; // <- only run the following part of the code once
+ const toggle = this;
+
+ /* User is teacher/editor for the given medium and visible_for_teacher ist activated?
+ -> add toggle annotations button */
+ $.ajax(Routes.check_annotation_visibility_path(thymeAttributes.mediumId), {
+ type: "GET",
+ dataType: "json",
+ success: function (isPermitted) {
+ if (!isPermitted) {
+ return;
+ }
+ for (const annotation of thymeAttributes.annotations) {
+ // Only show toggle if there is at least one foreign annotation
+ if (!annotation.belongsToCurrentUser) {
+ toggle.show();
+ toggle.element.addEventListener("click", function () {
+ thymeAttributes.annotationManager.updateAnnotations();
+ });
+ // When loading the player, the toggle is set to "true" by default,
+ // so we have to trigger updateAnnotations() manually once.
+ thymeAttributes.annotationManager.updateAnnotations();
+ }
+ }
+ },
+ });
+ }
+
+ installListener() {
+ this.element.addEventListener("click", function () {
+ thymeAttributes.annotationManager.updateAnnotations();
+ });
+ }
+
+ /*
+ Returns true if the toggle's value is true and false otherwise.
+ */
+ getValue() {
+ return this.$check.is(":checked");
+ }
+
+ /*
+ Auxiliary method
+ */
+ show() {
+ $("#volume-controls").css("left", "66%");
+ $("#speed-control").css("left", "77%");
+ $("#annotation-button").css("left", "86%");
+ thymeAttributes.hideControlBarThreshold.x = 960;
+ this.div.show();
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/component.js b/app/assets/javascripts/thyme/components/component.js
new file mode 100644
index 000000000..a0677949c
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/component.js
@@ -0,0 +1,17 @@
+/**
+ The basic component class from which every thyme related components (slider, selector, etc.)
+ should be a subclass.
+*/
+// eslint-disable-next-line no-unused-vars
+class Component {
+ /*
+ element = The id of the HTML element associated to this button.
+ */
+ constructor(element) {
+ this.element = document.getElementById(element);
+ }
+
+ /* This method should add the button functionality to the given player.
+ Override it in the given subclass! */
+ add() { }
+}
diff --git a/app/assets/javascripts/thyme/components/full_screen_button.js b/app/assets/javascripts/thyme/components/full_screen_button.js
new file mode 100644
index 000000000..d8991456d
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/full_screen_button.js
@@ -0,0 +1,69 @@
+// eslint-disable-next-line no-unused-vars
+class FullScreenButton extends Component {
+ constructor(element, container) {
+ super(element);
+ this.container = container;
+ }
+
+ add() {
+ const element = this.element;
+ const container = this.container;
+ const button = this;
+
+ // Event listener for the full-screen button
+ // (unfortunately, lots of browser specific code).
+ element.addEventListener("click", function () {
+ if (element.dataset.status === "true") {
+ if (document.exitFullscreen) {
+ document.exitFullscreen();
+ }
+ else if (document.mozCancelFullScreen) {
+ document.mozCancelFullScreen();
+ }
+ else if (document.webkitExitFullscreen) {
+ document.webkitExitFullscreen();
+ }
+ }
+ else {
+ if (container.requestFullscreen) {
+ container.requestFullscreen();
+ }
+ else if (container.mozRequestFullScreen) {
+ container.mozRequestFullScreen();
+ }
+ else if (container.webkitRequestFullscreen) {
+ container.webkitRequestFullscreen();
+ }
+ }
+ });
+
+ document.onfullscreenchange = function () {
+ button.#fullscreenChange();
+ };
+
+ document.onwebkitfullscreenchange = function () {
+ button.#fullscreenChange();
+ };
+
+ document.onmozfullscreenchange = function () {
+ button.#fullscreenChange();
+ };
+ }
+
+ #fullscreenChange() {
+ if (document.fullscreenElement) {
+ // User enters fullscreen mode
+ this.element.innerHTML = "fullscreen_exit";
+ this.element.dataset.status = "true";
+ /* Set height to 100vh in fullscreen mode as it otherwise
+ is too large. */
+ $(thymeAttributes.video).css("height", "100vh");
+ }
+ else {
+ // User exists fullscreen mode
+ this.element.innerHTML = "fullscreen";
+ this.element.dataset.status = "false";
+ $(thymeAttributes.video).css("height", "100%");
+ }
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/ia_back_button.js b/app/assets/javascripts/thyme/components/ia_back_button.js
new file mode 100644
index 000000000..44ef372b8
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/ia_back_button.js
@@ -0,0 +1,39 @@
+/**
+ * The Interactive Area Back Button saves a reference on the
+ * current time. If one clicks on a chapter field in the
+ * interactive area (which sets the current time to the start
+ * of the chapter), one has the possibility to go back by
+ * clicking this button.
+ */
+// eslint-disable-next-line no-unused-vars
+class IaBackButton extends Component {
+ constructor(element, chapterListId) {
+ super(element);
+ this.chapterListId = chapterListId;
+ }
+
+ add() {
+ // Event Handler for Back Button
+ this.element.addEventListener("click", function () {
+ video.currentTime = this.dataset.time;
+ $(this).hide();
+ });
+ }
+
+ update() {
+ // set up back button (transports back to the current chapter)
+ this.element.dataset.time = video.currentTime;
+ const currentChapter = $("#" + this.chapterListId + " .current");
+ if (currentChapter.length > 0) {
+ let backInfo = currentChapter.data("text").split(":", 1)[0];
+ if (backInfo && backInfo.length > 20) {
+ backInfo = this.element.dataset.back;
+ }
+ else {
+ backInfo = this.element.dataset.backto + backInfo;
+ }
+ $(this.element).empty().append(backInfo).show();
+ thymeUtility.renderLatex(this.element);
+ }
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/ia_button.js b/app/assets/javascripts/thyme/components/ia_button.js
new file mode 100644
index 000000000..a848e8d4c
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/ia_button.js
@@ -0,0 +1,76 @@
+/**
+ * The Interactive Area Button can show/hide specific elements of the
+ * thyme player (normally interactive and annotation area) and
+ * adjust the video position/size accordingly.
+ */
+// eslint-disable-next-line no-unused-vars
+class IaButton extends Component {
+ /*
+ toHide = An array consisting of all the components that
+ should be hidden/shown when this button is clicked.
+ These components must provide a show() and hide()
+ method, but they havn't to be a JQuery reference
+ on a HTML element.
+
+ toShrink = An array consisting of JQuery references of all
+ the components that should grow/shrink when this
+ button is clicked.
+
+ shrink = The percentage telling how much the elements of toShrink
+ should shrink when the components of toHide are shown.
+ */
+ constructor(element, toHide, toShrink, shrink) {
+ super(element);
+ this.toHide = toHide;
+ this.toShrink = toShrink;
+ this.shrink = shrink;
+ }
+
+ add() {
+ const element = this.element;
+ const button = this;
+
+ element.addEventListener("click", function () {
+ if (element.dataset.status === "true") {
+ button.plus();
+ }
+ else {
+ button.minus();
+ }
+ });
+ }
+
+ /*
+ Sets the button to its plus value, i.e. shows all
+ toHide elements and shrinks all toShrink elements.
+ */
+ plus() {
+ this.#aux("false", "remove_from_queue", false, "100%");
+ thymeAttributes.annotationArea.unmarkCurrentAnnotationAsShown();
+ }
+
+ /*
+ Sets the button to its minus value, i.e. hides all
+ toHide elements and enlarges all toShrink elements.
+ */
+ minus() {
+ this.#aux("true", "add_to_queue", true, this.shrink);
+ }
+
+ getStatus() {
+ return this.element.dataset.status === "true";
+ }
+
+ #aux(status, innerHTML, sh, size) {
+ this.element.dataset.status = status;
+ this.element.innerHTML = innerHTML;
+ for (let e of this.toHide) {
+ sh ? e.show() : e.hide();
+ }
+ for (let e of this.toShrink) {
+ e.css("width", size);
+ }
+ $(window).trigger("resize");
+ thymeAttributes.annotationManager.updateMarkers();
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/ia_close_button.js b/app/assets/javascripts/thyme/components/ia_close_button.js
new file mode 100644
index 000000000..b2ae8e3cb
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/ia_close_button.js
@@ -0,0 +1,18 @@
+/**
+ * The Interactive Area button gives a shortcut
+ * for the minus event of an IaButton.
+ */
+// eslint-disable-next-line no-unused-vars
+class IaCloseButton extends Component {
+ constructor(element, iaButton) {
+ super(element);
+ this.iaButton = iaButton;
+ }
+
+ add() {
+ const iaButton = this.iaButton;
+ this.element.addEventListener("click", function () {
+ iaButton.plus();
+ });
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/mute_button.js b/app/assets/javascripts/thyme/components/mute_button.js
new file mode 100644
index 000000000..5a662b21a
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/mute_button.js
@@ -0,0 +1,18 @@
+// eslint-disable-next-line no-unused-vars
+class MuteButton extends Component {
+ add() {
+ const video = thymeAttributes.video;
+ const element = this.element;
+
+ element.addEventListener("click", function () {
+ if (video.muted) {
+ video.muted = true;
+ element.innerHTML = "volume_off";
+ }
+ else {
+ video.muted = false;
+ element.innerHTML = "volume_up";
+ }
+ });
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/next_chapter_button.js b/app/assets/javascripts/thyme/components/next_chapter_button.js
new file mode 100644
index 000000000..011ae8127
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/next_chapter_button.js
@@ -0,0 +1,15 @@
+// eslint-disable-next-line no-unused-vars
+class NextChapterButton extends Component {
+ add() {
+ const video = thymeAttributes.video;
+ const element = this.element;
+
+ // Event handler for the nextChapter button
+ element.addEventListener("click", function () {
+ const next = thymeAttributes.chapterManager.nextChapterStart();
+ if (next) {
+ video.currentTime = thymeAttributes.chapterManager.nextChapterStart();
+ }
+ });
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/play_button.js b/app/assets/javascripts/thyme/components/play_button.js
new file mode 100644
index 000000000..8e8f1c6d7
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/play_button.js
@@ -0,0 +1,24 @@
+// eslint-disable-next-line no-unused-vars
+class PlayButton extends Component {
+ add() {
+ const video = thymeAttributes.video;
+ const element = this.element;
+
+ element.addEventListener("click", function () {
+ if (video.paused) {
+ video.play();
+ }
+ else {
+ video.pause();
+ }
+ });
+
+ video.onplay = function () {
+ element.innerHTML = "pause";
+ };
+
+ video.onpause = function () {
+ element.innerHTML = "play_arrow";
+ };
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/previous_chapter_button.js b/app/assets/javascripts/thyme/components/previous_chapter_button.js
new file mode 100644
index 000000000..3a2d277a0
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/previous_chapter_button.js
@@ -0,0 +1,15 @@
+// eslint-disable-next-line no-unused-vars
+class PreviousChapterButton extends Component {
+ add() {
+ const video = thymeAttributes.video;
+ const element = this.element;
+
+ // Event handler for the previousChapter button
+ element.addEventListener("click", function () {
+ const previous = thymeAttributes.chapterManager.previousChapterStart();
+ if (previous) {
+ video.currentTime = thymeAttributes.chapterManager.previousChapterStart();
+ }
+ });
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/seek_bar.js b/app/assets/javascripts/thyme/components/seek_bar.js
new file mode 100644
index 000000000..805d632b1
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/seek_bar.js
@@ -0,0 +1,70 @@
+// eslint-disable-next-line no-unused-vars
+class SeekBar extends Component {
+ constructor(element) {
+ super(element);
+ thymeAttributes.seekBar = this; // save a reference for this seek bar
+ }
+
+ add() {
+ const video = thymeAttributes.video;
+ const element = this.element;
+
+ // Event listeners for the seek bar
+ element.addEventListener("input", function () {
+ const time = video.duration * element.value / 100;
+ video.currentTime = time;
+ });
+
+ // if videomedtadata have been loaded, set up seek bar
+ video.addEventListener("loadedmetadata", function () {
+ if (video.dataset.time) {
+ element.value = video.currentTime / video.duration * 100;
+ }
+ else {
+ element.value = 0;
+ }
+ });
+
+ // Update the seek bar as the video plays.
+ // Uses a gradient for seekbar video time visualization.
+ video.addEventListener("timeupdate", function () {
+ const value = 100 / video.duration * video.currentTime;
+ element.value = value;
+ element.style.backgroundImage = "linear-gradient(to right,"
+ + " #2497E3, #2497E3 "
+ + value
+ + "%, #ffffff "
+ + value
+ + "%, #ffffff)";
+ const currentTime = document.getElementById("current-time");
+ currentTime.innerHTML = thymeUtility.secondsToTime(video.currentTime);
+ });
+
+ // Pause the video when the seek handle is being dragged
+ element.addEventListener("mousedown", function () {
+ video.dataset.paused = video.paused;
+ video.pause();
+ });
+
+ // Play the video when the seek handle is dropped
+ element.addEventListener("mouseup", function () {
+ if (video.dataset.paused !== "true") {
+ video.play();
+ }
+ });
+ }
+
+ /*
+ If mouse is moved over seek bar, display tooltip with current chapter
+ (only use this if the given thyme player provides chapters!).
+ */
+ addChapterTooltips() {
+ const element = this.element;
+
+ element.addEventListener("mousemove", function (_evt) {
+ const previous = thymeAttributes.chapterManager.previousChapterStart();
+ const info = $("#c-" + $.escapeSelector(previous)).text().split(":")[0];
+ element.setAttribute("title", info);
+ });
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/speed_selector.js b/app/assets/javascripts/thyme/components/speed_selector.js
new file mode 100644
index 000000000..f427b7d0c
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/speed_selector.js
@@ -0,0 +1,26 @@
+// eslint-disable-next-line no-unused-vars
+class SpeedSelector extends Component {
+ constructor(element) {
+ super(element);
+ }
+
+ /* This method should add the button functionality to the given player.
+ Override it in the given subclass! */
+ add() {
+ const video = thymeAttributes.video;
+ const element = this.element;
+
+ element.addEventListener("click", function () {
+ if (video.preservesPitch) {
+ video.preservesPitch = true;
+ }
+ else if (video.mozPreservesPitch) {
+ video.mozPreservesPitch = true;
+ }
+ else if (video.webkitPreservesPitch) {
+ video.webkitPreservesPitch = true;
+ }
+ video.playbackRate = this.options[this.selectedIndex].value;
+ });
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/time_button.js b/app/assets/javascripts/thyme/components/time_button.js
new file mode 100644
index 000000000..c5c26af7a
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/time_button.js
@@ -0,0 +1,24 @@
+// eslint-disable-next-line no-unused-vars
+class TimeButton extends Component {
+ /*
+ time = The time to add in seconds.
+ */
+ constructor(element, time) {
+ super(element);
+ this.time = time;
+ }
+
+ add() {
+ const video = thymeAttributes.video;
+ const time = this.time;
+
+ this.element.addEventListener("click", function () {
+ if (time >= 0) {
+ video.currentTime = Math.min(video.currentTime + time, video.duration);
+ }
+ else {
+ video.currentTime = Math.max(video.currentTime + time, 0);
+ }
+ });
+ }
+}
diff --git a/app/assets/javascripts/thyme/components/volume_bar.js b/app/assets/javascripts/thyme/components/volume_bar.js
new file mode 100644
index 000000000..b1db66fc2
--- /dev/null
+++ b/app/assets/javascripts/thyme/components/volume_bar.js
@@ -0,0 +1,33 @@
+// eslint-disable-next-line no-unused-vars
+class VolumeBar extends Component {
+ add() {
+ const video = thymeAttributes.video;
+ const element = this.element;
+
+ // Event listener for the volume bar
+ element.addEventListener("input", function () {
+ video.volume = element.value;
+ });
+
+ video.addEventListener("loadedmetadata", function () {
+ element.value = video.volume;
+ element.style.backgroundImage = "linear-gradient(to right,"
+ + " #2497E3, #2497E3 "
+ + video.volume * 100
+ + "%, #ffffff "
+ + video.volume * 100
+ + "%, #ffffff)";
+ });
+
+ video.addEventListener("volumechange", function () {
+ const value = video.volume;
+ element.value = value;
+ element.style.backgroundImage = "linear-gradient(to right,"
+ + " #2497E3, #2497E3 "
+ + value * 100
+ + "%, #ffffff "
+ + value * 100
+ + "%, #ffffff)";
+ });
+ }
+}
diff --git a/app/assets/javascripts/thyme/control_bar_hider.js b/app/assets/javascripts/thyme/control_bar_hider.js
new file mode 100644
index 000000000..dc98346f3
--- /dev/null
+++ b/app/assets/javascripts/thyme/control_bar_hider.js
@@ -0,0 +1,74 @@
+/**
+ * This class contains the functionality for (auto-)hiding the control bar.
+ */
+// eslint-disable-next-line no-unused-vars
+class ControlBarHider {
+ /*
+ controlBarId = The ID of the control bar.
+ delay = The delay after which the control bar is automatically hidden.
+ */
+ constructor(controlBarId, delay) {
+ this.controlBarId = controlBarId;
+ this.delay = delay;
+ this.hideBlocker = false; // helper attribute
+ }
+
+ /*
+ Installs the control bar hider, i.e. after calling this method it will
+ start working.
+ */
+ install() {
+ const controlBarHider = this;
+ const controlBar = document.getElementById(this.controlBarId);
+ const video = thymeAttributes.video;
+
+ // show control bar when mouse is moved, etc.
+ function show() {
+ controlBarHider.showControlBar(); // <- need this extra function for reference
+ }
+ /* NOTE: Why do we need the mouseover listener? To trigger it, the mouse
+ has to be moved, i.e. the second event listener is triggered. */
+ video.addEventListener("mouseover", show);
+ video.addEventListener("mousemove", show);
+ video.addEventListener("touchstart", show);
+ video.addEventListener("click", show);
+
+ // block hiding if curser is over the control bar
+ controlBar.addEventListener("mouseover", function () {
+ controlBarHider.hideBlocker = true;
+ });
+ controlBar.addEventListener("mouseleave", function () {
+ controlBarHider.hideBlocker = false;
+ });
+
+ // auto hide control bar
+ let t = void 0;
+ function resetTimer() {
+ clearTimeout(t);
+ t = setTimeout(function () {
+ if (controlBarHider.hideBlocker) {
+ return;
+ }
+ controlBarHider.hideControlBar();
+ }, controlBarHider.delay);
+ }
+ window.onload = resetTimer;
+ window.onmousemove = resetTimer;
+ window.onmousedown = resetTimer;
+ window.ontouchstart = resetTimer;
+ window.onclick = resetTimer;
+ }
+
+ /*
+ AUXILIARY METHODS
+ */
+ showControlBar() {
+ $("#" + this.controlBarId).css("visibility", "visible");
+ $(thymeAttributes.video).css("cursor", "");
+ }
+
+ hideControlBar() {
+ $("#" + this.controlBarId).css("visibility", "hidden");
+ $(thymeAttributes.video).css("cursor", "none");
+ }
+}
diff --git a/app/assets/javascripts/thyme/display_manager.js b/app/assets/javascripts/thyme/display_manager.js
new file mode 100644
index 000000000..d28eca7de
--- /dev/null
+++ b/app/assets/javascripts/thyme/display_manager.js
@@ -0,0 +1,60 @@
+/**
+ * A DisplayManager helps to switch between the full thyme player and
+ * the native HTML player shown on small devices.
+ */
+// eslint-disable-next-line no-unused-vars
+class DisplayManager {
+ constructor(elements, onEnlarge) {
+ /*
+ elements = An array containing JQuery references on the HTML elements
+ that should be hidden, when the display is too small.
+
+ onEnlarge = A reference to a function that is called when the display
+ changes from small to large. Use this for player specific behavior.
+ */
+ this.elements = elements;
+ this.onEnlarge = onEnlarge;
+ }
+
+ // on small display, fall back to standard browser player
+ adaptToSmallDisplay() {
+ for (let e of this.elements) {
+ e.hide();
+ }
+ thymeAttributes.video.style.width = "100%";
+ thymeAttributes.video.controls = true;
+ }
+
+ // on large display, use anything thyme has to offer, disable native player
+ adaptToLargeDisplay() {
+ thymeAttributes.video.controls = false;
+ for (let e of this.elements) {
+ e.show();
+ }
+ this.onEnlarge();
+ }
+
+ // Check screen size and trigger the right method
+ updateControlBarType() {
+ const manager = this;
+
+ const matchSmallMediaQuery = window.matchMedia(`
+ screen and (
+ (max-width: ${thymeAttributes.hideControlBarThreshold.x}px)
+ or (max-height: ${thymeAttributes.hideControlBarThreshold.y}px)
+ )
+ `);
+
+ function handleSizeChange(event) {
+ if (event.matches) {
+ manager.adaptToSmallDisplay();
+ }
+ else {
+ manager.adaptToLargeDisplay();
+ }
+ }
+
+ matchSmallMediaQuery.addListener(handleSizeChange);
+ handleSizeChange(matchSmallMediaQuery); // initial call
+ }
+}
diff --git a/app/assets/javascripts/thyme/heatmap.js b/app/assets/javascripts/thyme/heatmap.js
new file mode 100644
index 000000000..463669ffb
--- /dev/null
+++ b/app/assets/javascripts/thyme/heatmap.js
@@ -0,0 +1,122 @@
+/**
+ * Objects of this class represent heatmaps. It provides the function draw() which
+ * draws the heatmap to the thyme player.
+ */
+// eslint-disable-next-line no-unused-vars
+class Heatmap {
+ static RADIUS = 10; // this number adjusts the radius of the peaks of the heatmap
+ static MAX_HEIGHT = 0.25; // this number adjusts the maximum heights of the heatmap peaks
+
+ /*
+ * id = The ID of the HTML element to which the heatmap will be appended.
+ */
+ constructor(id) {
+ this.heatmap = $("#" + id);
+ this.categories = [];
+ }
+
+ draw() {
+ if (!thymeAttributes.annotations) {
+ return;
+ }
+ this.heatmap.empty();
+
+ /*
+ variable definitions
+ */
+ // We assume a slightly bigger width to be able to display sine waves
+ // also at the beginning and the end of the timeline.
+ const stickOutWidthOneSided = Heatmap.RADIUS;
+ const thresh = 20; // a small additional width to avoid the heatmap to be cut off
+ const seekBarWidth = thymeAttributes.seekBar.element.clientWidth;
+ const width = seekBarWidth + 2 * stickOutWidthOneSided + thresh;
+ const stretch = seekBarWidth / (seekBarWidth + 4 * stickOutWidthOneSided + thresh);
+
+ const maxHeight = video.clientHeight * Heatmap.MAX_HEIGHT;
+ this.heatmap.css("top", -maxHeight - 11); // vertical offset
+
+ const numDivisons = width + 4 * Heatmap.RADIUS + 1;
+ /* An array for each pixel on the timeline. The indices of this array should be thought
+ of the x-axis of the heatmap's graph, while its entries should be thought of its
+ values on the y-axis. */
+ let pixels = new Array(numDivisons).fill(0);
+ /* amplitude should be calculated with respect to all annotations
+ (even those which are not shown). Otherwise the peaks increase
+ when turning off certain annotations because the graph has to be
+ normed. Therefore we need this additional "pixelsAll" array. */
+ let pixelsAll = new Array(numDivisons).fill(0);
+ /* for any visible annotation, this array contains its color (needed for the calculation
+ of the heatmap color) */
+ let colors = [];
+
+ /*
+ data calculation
+ */
+ for (const a of thymeAttributes.annotations) {
+ const valid = this.#isValidCategory(a.category)
+ && AnnotationCategoryToggle.isChecked(a.category);
+
+ if (valid) {
+ colors.push(a.category.color);
+ }
+ const time = a.seconds;
+ const position = Math.round(stretch * width * (time / video.duration));
+ for (let x = position - Heatmap.RADIUS; x <= position + Heatmap.RADIUS; x++) {
+ let y = Heatmap.#sinX(x, position, Heatmap.RADIUS);
+ pixelsAll[x + Heatmap.RADIUS] += y;
+ if (valid) {
+ pixels[x + Heatmap.RADIUS] += y;
+ }
+ }
+ }
+ const maxValue = Math.max(...pixelsAll);
+ const amplitude = maxValue != 0 ? maxHeight * (1 / maxValue) : 0;
+
+ /*
+ Construct heatmap SVG
+ */
+ let pointsStr = `0,${maxHeight} `;
+ for (let x = 0; x < pixels.length; x++) {
+ pointsStr += `${x},${maxHeight - amplitude * pixels[x]} `;
+ }
+ pointsStr += `${width},${maxHeight}`;
+
+ const heatmapStr = `
+
+ `;
+ this.heatmap.append(heatmapStr);
+ }
+
+ addCategory(category) {
+ if (this.categories.includes(category)) {
+ return;
+ }
+ this.categories.push(category);
+ }
+
+ removeCategory(category) {
+ this.categories = this.categories.filter(c => c !== category);
+ }
+
+ /*
+ AUXILIARY METHODS
+ */
+
+ #isValidCategory(category) {
+ return this.categories.includes(category);
+ }
+
+ /* A modified sine function for building nice peaks around the marker positions.
+
+ x = insert value
+ position = the position of the maximum value
+ */
+ static #sinX(x, position) {
+ return (1 + Math.sin(Math.PI / Heatmap.RADIUS * (x - position) + Math.PI / 2)) / 2;
+ }
+}
diff --git a/app/assets/javascripts/thyme/key_shortcuts.js b/app/assets/javascripts/thyme/key_shortcuts.js
new file mode 100644
index 000000000..c30b3fb45
--- /dev/null
+++ b/app/assets/javascripts/thyme/key_shortcuts.js
@@ -0,0 +1,135 @@
+/**
+ All key shortcuts should be bundled here.
+*/
+// eslint-disable-next-line no-unused-vars
+const thymeKeyShortcuts = {
+ /*
+ SHORTCUT LIST:
+ Arrow right - plus ten seconds
+ Arrow left - minus ten seconds
+ f - fullscreen
+ Page up - volume up
+ Page down - volume down
+ m - mute
+ */
+ addGeneralShortcuts: function () {
+ const video = document.getElementById("video");
+
+ window.addEventListener("keydown", function (evt) {
+ if (thymeAttributes.lockKeyListeners) {
+ return;
+ }
+ const key = evt.key;
+ if (key === " ") {
+ if (video.paused) {
+ video.play();
+ }
+ else {
+ video.pause();
+ }
+ }
+ else if (key === "ArrowRight") {
+ $("#plus-ten").trigger("click");
+ }
+ else if (key === "ArrowLeft") {
+ $("#minus-ten").trigger("click");
+ }
+ else if (key === "f") {
+ $("#full-screen").trigger("click");
+ }
+ else if (key === "m") {
+ $("#mute").trigger("click");
+ }
+ else if (key === "PageUp") {
+ video.volume = Math.min(video.volume + 0.1, 1);
+ }
+ else if (key === "PageDown") {
+ video.volume = Math.max(video.volume - 0.1, 0);
+ }
+ });
+ },
+
+ /*
+ Thyme player specific
+
+ SHORTCUT LIST:
+ Arrow Up - next chapter
+ Arrow Down - previous chapter
+ i - toggle interactive area
+ */
+ addPlayerShortcuts() {
+ window.addEventListener("keydown", function (evt) {
+ if (thymeAttributes.lockKeyListeners) {
+ return;
+ }
+ const key = evt.key;
+ if (key === "i") {
+ $("#ia-active").trigger("click");
+ }
+ else if (key === "ArrowUp") {
+ $("#next-chapter").trigger("click");
+ }
+ else if (key === "ArrowDown") {
+ $("#previous-chapter").trigger("click");
+ }
+
+ // annotation-related shortcuts
+ if (thymeAttributes.disableAnnotationKeyListeners) {
+ return;
+ }
+ else if (key === "a") {
+ $("#annotation-previous-button").trigger("click");
+ }
+ else if (key === "s") {
+ $("#annotation-goto-button").trigger("click");
+ }
+ else if (key === "d") {
+ $("#annotation-next-button").trigger("click");
+ }
+ });
+ },
+
+ /*
+ Thyme feedback specific
+
+ SHORTCUT LIST:
+ q - toggle mistake annotations
+ w - toggle presentation annotations
+ e - toggle content annotations
+ r - toggle note annotations
+ */
+ addFeedbackShortcuts() {
+ window.addEventListener("keydown", function (evt) {
+ if (thymeAttributes.lockKeyListeners || thymeAttributes.disableAnnotationKeyListeners) {
+ return;
+ }
+ const key = evt.key;
+ if (key === "q") {
+ $("#annotation-category-mistake-switch").trigger("click");
+ }
+ else if (key === "w") {
+ $("#annotation-category-content-switch").trigger("click");
+ }
+ else if (key === "e") {
+ $("#annotation-category-presentation-switch").trigger("click");
+ }
+ else if (key === "r") {
+ $("#annotation-category-note-switch").trigger("click");
+ }
+ else if (key === "a") {
+ $("#annotation-previous-button").trigger("click");
+ }
+ else if (key === "s") {
+ $("#annotation-goto-button").trigger("click");
+ }
+ else if (key === "d") {
+ $("#annotation-next-button").trigger("click");
+ }
+ });
+ },
+
+ /*
+ Thyme editor specific
+ */
+ // Add editor specific keyboard shortcuts here.
+};
diff --git a/app/assets/javascripts/thyme/metadata_manager.js b/app/assets/javascripts/thyme/metadata_manager.js
new file mode 100644
index 000000000..760e58bb4
--- /dev/null
+++ b/app/assets/javascripts/thyme/metadata_manager.js
@@ -0,0 +1,219 @@
+/**
+ This file wraps up most functionality of the thyme player(s) concerning metadata.
+*/
+// eslint-disable-next-line no-unused-vars
+class MetadataManager {
+ constructor(metadataListId) {
+ this.metadataListId = metadataListId;
+ }
+
+ load() {
+ let initialMetadata = true;
+ const videoId = thymeAttributes.video.id;
+ const metadataElement = $("#" + videoId + ' track[kind="metadata"]').get(0);
+ const metadataManager = this;
+
+ /* after video metadata have been loaded, display chapters in the interactive area
+ Originally (and more appropriately, according to the standards),
+ only the 'loadedmetadata' event was used. However, Firefox triggers this event too soon,
+ i.e. when the readyStates for chapters and elements are 1 (loading) instead of 2 (loaded)
+ for the events, see https://www.w3schools.com/jsref/event_oncanplay.asp */
+ video.addEventListener("loadedmetadata", function () {
+ if (initialMetadata && metadataElement.readyState === 2) {
+ metadataManager.#displayMetadata();
+ initialMetadata = false;
+ }
+ });
+ video.addEventListener("canplay", function () {
+ if (initialMetadata && metadataElement.readyState === 2) {
+ metadataManager.#displayMetadata();
+ initialMetadata = false;
+ }
+ });
+ }
+
+ /* returns the jQuery object of all metadata elements that start before the
+ given time in seconds */
+ #metadataBefore(seconds) {
+ return $('[id^="m-"]').not(this.#metadataAfter(seconds));
+ }
+
+ /* returns the jQuery object of all metadata elements that start after the
+ given time in seconds */
+ #metadataAfter(seconds) {
+ const metaList = document.getElementById(this.metadataListId);
+ const times = JSON.parse(metaList.dataset.times);
+ if (times.length === 0) {
+ return $();
+ }
+ for (let i = 0; i < times.length; i++) {
+ if (times[i] > seconds) {
+ const $nextMeta = $("#m-" + $.escapeSelector(times[i]));
+ return $nextMeta.add($nextMeta.nextAll());
+ }
+ }
+ return $();
+ }
+
+ /* for a given time, show all metadata elements that start before this time
+ and hide all that start later */
+ metaIntoView(time) {
+ this.#metadataAfter(time).hide();
+ const $before = this.#metadataBefore(time);
+ $before.show();
+ const previousLength = $before.length;
+ if (previousLength > 0) {
+ $before.get(previousLength - 1).scrollIntoView();
+ }
+ }
+
+ // set up the metadata elements
+ #displayMetadata() {
+ const video = thymeAttributes.video;
+ const metadataManager = this;
+ const metadataListId = this.metadataListId;
+ const $metaList = $("#" + metadataListId);
+ const metadataElement = $("#" + video.id + ' track[kind="metadata"]').get(0);
+
+ let metaTrack;
+ if (metadataElement.readyState === 2 && (metaTrack = metadataElement.track)) {
+ metaTrack.mode = "hidden";
+ let times = [];
+ // read out the metadata track cues and generate html elements for
+ // metadata, run katex on them
+ for (let i = 0; i < metaTrack.cues.length; i++) {
+ const cue = metaTrack.cues[i];
+ const meta = JSON.parse(cue.text);
+ const start = cue.startTime;
+ times.push(start);
+ const $listItem = $(" ", {
+ id: "m-" + start,
+ });
+ $listItem.hide();
+ const $link = $(" ", {
+ text: meta.reference,
+ class: "item",
+ id: "l-" + start,
+ });
+ const $videoIcon = $(" ", {
+ text: "video_library",
+ class: "material-icons",
+ });
+ const $videoRef = $(" ", {
+ href: meta.video,
+ target: "_blank",
+ });
+ $videoRef.append($videoIcon);
+ if (!meta.video) {
+ $videoRef.hide();
+ }
+ const $manIcon = $(" ", {
+ text: "library_books",
+ class: "material-icons",
+ });
+ const $manRef = $(" ", {
+ href: meta.manuscript,
+ target: "_blank",
+ });
+ $manRef.append($manIcon);
+ if (!meta.manuscript) {
+ $manRef.hide();
+ }
+ const $scriptIcon = $(" ", {
+ text: "menu_book",
+ class: "material-icons",
+ });
+ const $scriptRef = $(" ", {
+ href: meta.script,
+ target: "_blank",
+ });
+ $scriptRef.append($scriptIcon);
+ if (!meta.script) {
+ $scriptRef.hide();
+ }
+ const $quizIcon = $(" ", {
+ text: "videogame_asset",
+ class: "material-icons",
+ });
+ const $quizRef = $(" ", {
+ href: meta.quiz,
+ target: "_blank",
+ });
+ $quizRef.append($quizIcon);
+ if (!meta.quiz) {
+ $quizRef.hide();
+ }
+ const $extIcon = $(" ", {
+ text: "link",
+ class: "material-icons",
+ });
+ const $extRef = $(" ", {
+ href: meta.link,
+ target: "_blank",
+ });
+ $extRef.append($extIcon);
+ if (!meta.link) {
+ $extRef.hide();
+ }
+ const $description = $("
", {
+ text: meta.text,
+ class: "mx-3",
+ });
+ const $explanation = $("
", {
+ text: meta.explanation,
+ class: "m-3",
+ });
+ const $details = $("
");
+ $details.append($link).append($description).append($explanation);
+ let $icons = $("
", {
+ style: "flex-shrink: 3; display: flex; flex-direction: column;",
+ });
+ $icons.append($videoRef).append($manRef).append($scriptRef).append($quizRef).append($extRef);
+ $listItem.append($details).append($icons);
+ $metaList.append($listItem);
+ $videoRef.on("click", function () {
+ video.pause();
+ });
+ $manRef.on("click", function () {
+ video.pause();
+ });
+ $extRef.on("click", function () {
+ video.pause();
+ });
+ $link.on("click", function () {
+ // displayBackButton();
+ video.currentTime = this.id.replace("l-", "");
+ });
+ let metaElement = $listItem.get(0);
+ thymeUtility.renderLatex(metaElement);
+ }
+ // store metadata start times as data attribute
+ $metaList.get(0).dataset.times = JSON.stringify(times);
+ // if user jumps to a new position in the video, display all metadata
+ // that start before this time and hide all that start later
+ $(video).on("seeked", function () {
+ const time = video.currentTime;
+ metadataManager.metaIntoView(time);
+ });
+ // if the metadata cue changes, highlight all current media and scroll
+ // them into view
+ $(metaTrack).on("cuechange", function () {
+ let j = 0;
+ $("#" + metadataListId + " li").removeClass("current");
+ while (j < this.activeCues.length) {
+ const activeStart = this.activeCues[j].startTime;
+ let metalink = document.getElementById("m-" + activeStart);
+ if (metalink) {
+ $(metalink).show();
+ $(metalink).addClass("current");
+ }
+ ++j;
+ }
+ const currentLength = $("#" + metadataListId + " .current").length;
+ if (currentLength > 0) {
+ $("#" + metadataListId + " .current").get(length - 1).scrollIntoView();
+ }
+ });
+ }
+ }
+}
diff --git a/app/assets/javascripts/thyme/resizer.js b/app/assets/javascripts/thyme/resizer.js
new file mode 100644
index 000000000..7c51e3291
--- /dev/null
+++ b/app/assets/javascripts/thyme/resizer.js
@@ -0,0 +1,32 @@
+/**
+ Use the method here to resize thyme players.
+*/
+// eslint-disable-next-line no-unused-vars
+const Resizer = {
+ resizeContainer: function (container, factor, offset) {
+ // see https://stackoverflow.com/a/73425736/
+ const windowWidth = window.innerWidth;
+ const windowHeight = window.innerHeight;
+
+ const video = document.getElementById("video");
+ const $container = $(container);
+
+ let height = windowHeight;
+ const vWidth = video.videoWidth;
+ const vHeight = video.videoHeight;
+ let width = Math.floor((vWidth * windowHeight / vHeight) * factor) - offset;
+ if (width > windowWidth) {
+ const shrink = windowWidth / width;
+ height = Math.floor(height * shrink);
+ width = windowWidth;
+ }
+
+ const top = Math.floor(0.5 * (windowHeight - height));
+ const left = Math.floor(0.5 * (windowWidth - width));
+
+ $container.css("height", height + "px");
+ $container.css("width", width + "px");
+ $container.css("top", top + "px");
+ $container.css("left", left + "px");
+ },
+};
diff --git a/app/assets/javascripts/thyme/thyme_editor.js b/app/assets/javascripts/thyme/thyme_editor.js
new file mode 100644
index 000000000..2d3346d0e
--- /dev/null
+++ b/app/assets/javascripts/thyme/thyme_editor.js
@@ -0,0 +1,46 @@
+$(document).on("turbolinks:load", function () {
+ /*
+ VIDEO INITIALIZATION
+ */
+ // exit script if the current page has no thyme player
+ const thymeEdit = document.getElementById("thyme-edit");
+ if (!thymeEdit) {
+ return;
+ }
+ // initialize attributes
+ const video = document.getElementById("video-edit");
+ thymeAttributes.video = video;
+ thymeAttributes.mediumId = thymeEdit.dataset.medium;
+
+ const canvasId = "snapshot";
+
+ // Adjust the width of the canvas according to the video
+ // such that screenshot generation is performed with the same ratio.
+ video.addEventListener("loadedmetadata", () => {
+ this.canvas = document.getElementById(canvasId);
+ this.canvas.width = Math.floor($(video).width());
+ this.canvas.height = Math.floor($(video).height());
+ });
+
+ /*
+ COMPONENTS
+ */
+ (new PlayButton("play-pause")).add();
+ (new MuteButton("mute")).add();
+
+ (new TimeButton("plus-ten", 10)).add();
+ (new TimeButton("plus-five", 5)).add();
+ (new TimeButton("plus-one", 1)).add();
+ (new TimeButton("minus-ten", -10)).add();
+ (new TimeButton("minus-five", -5)).add();
+ (new TimeButton("minus-one", -1)).add();
+
+ (new SeekBar("seek-bar")).add();
+ (new VolumeBar("volume-bar")).add();
+
+ (new AddItemButton("add-item")).add();
+ (new AddReferenceButton("add-reference")).add();
+ (new AddScreenshotButton("add-screenshot", canvasId)).add();
+
+ thymeUtility.setUpMaxTime("max-time");
+});
diff --git a/app/assets/javascripts/thyme/thyme_feedback.js b/app/assets/javascripts/thyme/thyme_feedback.js
new file mode 100644
index 000000000..1749b3be0
--- /dev/null
+++ b/app/assets/javascripts/thyme/thyme_feedback.js
@@ -0,0 +1,109 @@
+$(document).on("turbolinks:load", function () {
+ /*
+ VIDEO INITIALIZATION
+ */
+ // exit script if the current page has no thyme player
+ const thymeContainer = document.getElementById("thyme-feedback-container");
+ if (!thymeContainer) {
+ return;
+ }
+
+ // background color
+ document.body.style.backgroundColor = "black";
+
+ // initialize attributes
+ const video = document.getElementById("video");
+ thymeAttributes.video = video;
+ thymeAttributes.mediumId = document.getElementById("thyme-feedback").dataset.medium;
+ thymeAttributes.markerBarId = "feedback-markers";
+
+ /*
+ COMPONENTS
+ */
+ // Buttons
+ (new MuteButton("mute")).add();
+ (new PlayButton("play-pause")).add();
+ (new TimeButton("plus-ten", 10)).add();
+ (new TimeButton("minus-ten", -10)).add();
+ // Sliders
+ (new VolumeBar("volume-bar")).add();
+ seekBar = new SeekBar("seek-bar");
+ seekBar.add();
+
+ // heatmap
+ const heatmap = new Heatmap("heatmap");
+
+ // category toggles
+ const allCategories = Category.all();
+ const annotationCategoryToggles = new Array(allCategories.length);
+ let category;
+
+ for (let i = 0; i < allCategories.length; i++) {
+ category = allCategories[i];
+ annotationCategoryToggles[i] = new AnnotationCategoryToggle(category, heatmap);
+ annotationCategoryToggles[i].add();
+ }
+
+ /*
+ ANNOTATION FUNCTIONALITY
+ */
+ function colorFunc(annotation) {
+ return annotation.category.color;
+ }
+
+ function isValid(annotation) {
+ for (let toggle of annotationCategoryToggles) {
+ if (annotation.category === toggle.category && toggle.isChecked()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ const annotationArea = new AnnotationArea(false, colorFunc, null, isValid);
+ thymeAttributes.annotationArea = annotationArea;
+
+ function strokeColorFunc(annotation) {
+ return annotation.category === Category.MISTAKE ? "darkred" : "black";
+ }
+
+ function sizeFunc(annotation) {
+ return annotation.category === Category.MISTAKE;
+ }
+
+ function onClick(annotation) {
+ annotationArea.update(annotation);
+ }
+
+ function onUpdate() {
+ heatmap.draw();
+ }
+
+ const annotationManager = new AnnotationManager(colorFunc, strokeColorFunc, sizeFunc,
+ onClick, onUpdate, isValid);
+ thymeAttributes.annotationManager = annotationManager;
+ thymeAttributes.annotationFeatureActive = true;
+
+ /*
+ KEYBOARD SHORTCUTS
+ */
+ thymeKeyShortcuts.addGeneralShortcuts();
+ thymeKeyShortcuts.addFeedbackShortcuts();
+
+ /*
+ MISC
+ */
+ thymeUtility.playOnClick();
+ thymeUtility.setUpMaxTime("max-time");
+
+ // resizes the thyme container to the window dimensions
+ function resizeContainer() {
+ Resizer.resizeContainer(thymeContainer, 1 / 0.82, 0);
+ annotationManager.updateMarkers();
+ }
+
+ window.onresize = resizeContainer;
+ video.onloadedmetadata = resizeContainer;
+
+ $("#video").width("82%");
+});
diff --git a/app/assets/javascripts/thyme/thyme_player.js b/app/assets/javascripts/thyme/thyme_player.js
new file mode 100644
index 000000000..c8afaf225
--- /dev/null
+++ b/app/assets/javascripts/thyme/thyme_player.js
@@ -0,0 +1,185 @@
+$(document).on("turbolinks:load", function () {
+ /*
+ VIDEO INITIALIZATION
+ */
+ // exit script if the current page has no thyme player
+ const thymeContainer = document.getElementById("thyme-container");
+ if (!thymeContainer) {
+ return;
+ }
+
+ // background color
+ document.body.style.backgroundColor = "black";
+
+ // initialize attributes
+ const thyme = document.getElementById("thyme");
+ const video = document.getElementById("video");
+ thymeAttributes.video = video;
+ thymeAttributes.mediumId = thyme.dataset.medium;
+ thymeAttributes.markerBarId = "markers";
+
+ /*
+ COMPONENTS
+ */
+ // annotation components
+ const annotationFeatureActive = (document.getElementById("annotation-button") != null);
+ thymeAttributes.annotationFeatureActive = annotationFeatureActive;
+ if (annotationFeatureActive) {
+ (new AnnotationButton("annotation-button")).add();
+ }
+ const annotationsToggle = new AnnotationsToggle("annotations-toggle");
+
+ // regular components
+ (new FullScreenButton("full-screen", thymeContainer)).add();
+ (new MuteButton("mute")).add();
+ (new NextChapterButton("next-chapter")).add();
+ (new PlayButton("play-pause")).add();
+ (new PreviousChapterButton("previous-chapter")).add();
+ (new SpeedSelector("speed")).add();
+ (new TimeButton("plus-ten", 10)).add();
+ (new TimeButton("minus-ten", -10)).add();
+ // initialize iaButton here to have the reference but call add() later
+ // when we can define toHide (second argument which is set to null here)
+ const iaButton = new IaButton("ia-active", null, [$(video), $("#video-controlBar")], "82%");
+ (new VolumeBar("volume-bar")).add();
+ seekBar = new SeekBar("seek-bar");
+ seekBar.add();
+ seekBar.addChapterTooltips();
+
+ /*
+ ANNOTATION FUNCTIONALITY
+ */
+ // annotation manager and area
+ function colorFunc(annotation) {
+ return annotation.color;
+ }
+
+ function onClose() {
+ iaButton.minus();
+ }
+
+ function isValid(annotation) {
+ return (!annotationsToggle.getValue() && !annotation.belongsToCurrentUser ? false : true);
+ }
+
+ const annotationArea = new AnnotationArea(true, colorFunc, onClose, isValid);
+ thymeAttributes.annotationArea = annotationArea;
+
+ function strokeColorFunc(_annotation) {
+ return "black";
+ }
+
+ function sizeFunc(_annotation) {
+ return false;
+ }
+
+ function onClick(annotation) {
+ iaButton.minus();
+ annotationArea.update(annotation);
+ annotationArea.show();
+ $("#caption").hide();
+ }
+
+ function onUpdate() {
+ /* update might change the annotation which is currently shown in the
+ annotation area -> find the updated annotation in the annotation array
+ and update the area. */
+ if (annotationArea.annotation) {
+ const id = annotationArea.annotation.id;
+ annotationArea.update(AnnotationManager.find(id));
+ }
+ annotationsToggle.add();
+ }
+
+ const annotationManager = new AnnotationManager(colorFunc, strokeColorFunc, sizeFunc,
+ onClick, onUpdate, isValid);
+ thymeAttributes.annotationManager = annotationManager;
+
+ // Update annotations after deleting an annotation
+ const ANNOTATION_DELETE_SELECTOR = "#annotation-delete-button";
+ $(document).on("click", ANNOTATION_DELETE_SELECTOR, function () {
+ const deleteMsg = $(ANNOTATION_DELETE_SELECTOR).data("sureToDelete");
+ const reallyDelete = confirm(deleteMsg);
+ if (!reallyDelete) {
+ return;
+ }
+
+ const annotationId = Number(document.getElementById("annotation_id").textContent);
+ $.ajax(Routes.annotation_path(annotationId), {
+ type: "DELETE",
+ dataType: "json",
+ data: {
+ annotation_id: annotationId,
+ },
+ success: function () {
+ annotationManager.updateAnnotations();
+ // close and open again = show normal IA
+ iaButton.minus();
+ iaButton.plus();
+ },
+ });
+ });
+
+ /*
+ CHAPTERS & METADATA MANAGER
+ */
+ const iaBackButton = new IaBackButton("back-button", "chapters");
+ iaBackButton.add();
+ const chapterManager = new ChapterManager("chapters", iaBackButton);
+ const metadataManager = new MetadataManager("metadata");
+ thymeAttributes.chapterManager = chapterManager;
+ thymeAttributes.metadataManager = metadataManager;
+ chapterManager.load();
+ metadataManager.load();
+
+ /*
+ INTERACTIVE AREA
+ */
+ iaButton.toHide = [$("#caption"), annotationArea];
+ iaButton.add();
+ (new IaCloseButton("ia-close", iaButton)).add();
+
+ /*
+ RESIZE
+ */
+ // Manage large and small display
+ function onEnlarge() {
+ iaButton.plus();
+ }
+
+ const elements = [$("#caption"), $("#annotation-caption"), $("#video-controlBar")];
+ const displayManager = new DisplayManager(elements, onEnlarge);
+
+ // resizes the thyme container to the window dimensions, taking into account
+ // whether the interactive area is displayed or hidden
+ function resizeContainer() {
+ const factor = $("#caption").is(":hidden") && $("#annotation-caption").is(":hidden") ? 1 : 1 / 0.82;
+ Resizer.resizeContainer(thymeContainer, factor, 0);
+ annotationManager.updateMarkers();
+ }
+
+ window.onresize = resizeContainer;
+ video.onloadedmetadata = resizeContainer;
+
+ /*
+ KEYBOARD SHORTCUTS
+ */
+ thymeKeyShortcuts.addGeneralShortcuts();
+ thymeKeyShortcuts.addPlayerShortcuts();
+
+ /*
+ MISC
+ */
+ const controlBarHider = new ControlBarHider("video-controlBar", 3000);
+ controlBarHider.install();
+ displayManager.updateControlBarType();
+ thymeUtility.playOnClick();
+ thymeUtility.setUpMaxTime("max-time");
+
+ if (document.documentMode) {
+ alert($("body").data("badbrowser"));
+ displayManager.adaptToSmallDisplay();
+ resizeContainer();
+ return;
+ }
+});
diff --git a/app/assets/javascripts/thyme/utility.js b/app/assets/javascripts/thyme/utility.js
new file mode 100644
index 000000000..dd20d5e7a
--- /dev/null
+++ b/app/assets/javascripts/thyme/utility.js
@@ -0,0 +1,143 @@
+/**
+ This file contains some auxiliary functions used by the different thyme player types.
+*/
+const thymeUtility = {
+
+ /*
+ Mixes all colors in the array "colors" (write colors as hexadecimal, e.g. "#1fe67d").
+ */
+ mixColors: function (colors) {
+ let red = 0;
+ let green = 0;
+ let blue = 0;
+ for (let color of colors) {
+ red += Number("0x" + color.substr(5, 2));
+ green += Number("0x" + color.substr(3, 2));
+ blue += Number("0x" + color.substr(1, 2));
+ }
+ const n = colors.length;
+ red = Math.max(0, Math.min(255, Math.round(red / n)));
+ green = Math.max(0, Math.min(255, Math.round(green / n)));
+ blue = Math.max(0, Math.min(255, Math.round(blue / n)));
+ return "#" + thymeUtility.toHexaDecimal(blue)
+ + thymeUtility.toHexaDecimal(green)
+ + thymeUtility.toHexaDecimal(red);
+ },
+
+ /*
+ Convert given dataURL to Blob, used for converting screenshot canvas to png.
+ */
+ dataURLtoBlob: function (dataURL) {
+ // Decode the dataURL
+ const binary = atob(dataURL.split(",")[1]);
+ // Create 8-bit unsigned array
+ let array = [];
+ for (let i = 0; i < binary.length; i++) {
+ array.push(binary.charCodeAt(i));
+ }
+ // Return our Blob object
+ return new Blob([new Uint8Array(array)], {
+ type: "image/png",
+ });
+ },
+
+ /*
+ Lightens up a given color (given in a string in hexadecimal
+ representation "#xxyyzz") such that e.g. black becomes dark grey.
+ The higher the value of "factor" the brighter the colors become.
+ */
+ lightenUp: function (color, factor) {
+ const red = Math.floor(((factor - 1) * 255 + Number("0x" + color.substr(5, 2))) / factor);
+ const green = Math.floor(((factor - 1) * 255 + Number("0x" + color.substr(3, 2))) / factor);
+ const blue = Math.floor(((factor - 1) * 255 + Number("0x" + color.substr(1, 2))) / factor);
+ return "#" + thymeUtility.toHexaDecimal(blue)
+ + thymeUtility.toHexaDecimal(green)
+ + thymeUtility.toHexaDecimal(red);
+ },
+
+ /*
+ Installs a listener which lets the video play/pause when clicked.
+ */
+ playOnClick: function () {
+ const video = thymeAttributes.video;
+ video.addEventListener("click", function () {
+ if (video.paused) {
+ video.play();
+ }
+ else {
+ video.pause();
+ }
+ });
+ },
+
+ /*
+ Renders latex in a given HTML element.
+ */
+ renderLatex: function (element) {
+ renderMathInElement(element, {
+ delimiters: [
+ {
+ left: "$$",
+ right: "$$",
+ display: true,
+ }, {
+ left: "$",
+ right: "$",
+ display: false,
+ }, {
+ left: "\\(",
+ right: "\\)",
+ display: false,
+ }, {
+ left: "\\[",
+ right: "\\]",
+ display: true,
+ },
+ ],
+ throwOnError: false,
+ });
+ },
+
+ /*
+ Convert time in seconds to string of the form H:MM:SS.
+ */
+ secondsToTime: function (seconds) {
+ let date = new Date(null);
+ date.setSeconds(seconds);
+ return date.toISOString().substr(12, 7);
+ },
+
+ /*
+ Sets up the label on the right side of the seek bar which displays
+ the maximum time of the video.
+ (In order to make this work, we have to wait for the video's metadata
+ to be loaded.)
+ */
+ setUpMaxTime: function (maxTimeId) {
+ const video = thymeAttributes.video;
+ video.addEventListener("loadedmetadata", function () {
+ const maxTime = document.getElementById(maxTimeId);
+ maxTime.innerHTML = thymeUtility.secondsToTime(video.duration);
+ if (video.dataset.time) {
+ const time = video.dataset.time;
+ video.currentTime = time;
+ }
+ });
+ },
+
+ /*
+ Converts a json timestamp into a double value containing the absolute value of seconds.
+ */
+ timestampToSeconds: function (timestamp) {
+ return 3600 * timestamp.hours + 60 * timestamp.minutes + timestamp.seconds + 0.001 * timestamp.milliseconds;
+ },
+
+ /*
+ Converts a given integer between 0 and 255 into a hexadecimal, s.t. e.g. "15" becomes "0f"
+ (instead of just "f") -> needed for correct format.
+ */
+ toHexaDecimal: function (integer) {
+ return integer.toString(16).padStart(2, "0");
+ },
+
+};
diff --git a/app/assets/javascripts/thyme_editor.coffee b/app/assets/javascripts/thyme_editor.coffee
deleted file mode 100644
index dce0182cf..000000000
--- a/app/assets/javascripts/thyme_editor.coffee
+++ /dev/null
@@ -1,208 +0,0 @@
-# convert time in seconds to string of the form H:MM:SS
-secondsToTime = (seconds) ->
- date = new Date(null)
- date.setSeconds seconds
- return date.toISOString().substr(12, 7)
-
-# convert given dataURL to Blob, used for converting screenshot canvas to png
-dataURLtoBlob = (dataURL) ->
- # Decode the dataURL
- binary = atob(dataURL.split(',')[1])
- # Create 8-bit unsigned array
- array = []
- i = 0
- while i < binary.length
- array.push binary.charCodeAt(i)
- i++
- # Return our Blob object
- new Blob([ new Uint8Array(array) ], type: 'image/png')
-
-$(document).on 'turbolinks:load', ->
- thymeEdit = document.getElementById('thyme-edit')
- return if thymeEdit == null
- mediumId = thymeEdit.dataset.medium
- # Video
- video = document.getElementById('video-edit')
- # Buttons
- playButton = document.getElementById('play-pause')
- muteButton = document.getElementById('mute')
- plusTenButton = document.getElementById('plus-ten')
- plusFiveButton = document.getElementById('plus-five')
- plusOneButton = document.getElementById('plus-one')
- minusOneButton = document.getElementById('minus-one')
- minusFiveButton = document.getElementById('minus-five')
- minusTenButton = document.getElementById('minus-ten')
- addItemButton = document.getElementById('add-item')
- addReferenceButton = document.getElementById('add-reference')
- addScreenshotButton = document.getElementById('add-screenshot')
- # Sliders
- seekBar = document.getElementById('seek-bar')
- volumeBar = document.getElementById('volume-bar')
- # Time
- currentTime = document.getElementById('current-time')
- maxTime = document.getElementById('max-time')
- # ControlBar
- videoControlBar = document.getElementById('video-controlBar-edit')
- # Screenshot Canvas
- canvas = document.getElementById('snapshot')
-
- # Event listener for the play/pause button
- playButton.addEventListener 'click', ->
- if video.paused == true
- video.play()
- else
- video.pause()
- return
-
- video.onplay = ->
- playButton.innerHTML = 'pause'
-
- video.onpause = ->
- playButton.innerHTML = 'play_arrow'
-
- # Event listener for the mute button
- muteButton.addEventListener 'click', ->
- if video.muted == false
- video.muted = true
- muteButton.innerHTML = 'volume_off'
- else
- video.muted = false
- muteButton.innerHTML = 'volume_up'
- return
-
- # Event handler for the plusTen button
- plusTenButton.addEventListener 'click', ->
- video.currentTime = Math.min(video.currentTime + 10, video.duration)
- return
-
- # Event handler for the plusFive button
- plusFiveButton.addEventListener 'click', ->
- video.currentTime = Math.min(video.currentTime + 5, video.duration)
- return
-
- # Event handler for the plusOne button
- plusOneButton.addEventListener 'click', ->
- video.currentTime = Math.min(video.currentTime + 1, video.duration)
- return
-
- # Event handler for the minusOne button
- minusOneButton.addEventListener 'click', ->
- video.currentTime = Math.max(video.currentTime - 1, 0)
- return
-
- # Event handler for the minusFive button
- minusFiveButton.addEventListener 'click', ->
- video.currentTime = Math.max(video.currentTime - 5, 0)
- return
-
- # Event handler for the minusTen button
- minusTenButton.addEventListener 'click', ->
- video.currentTime = Math.max(video.currentTime - 10, 0)
- return
-
- # Event listener for the seek bar
- seekBar.addEventListener 'input', ->
- time = video.duration * seekBar.value / 100
- video.currentTime = time
- return
-
- # Event listener for addItem button
- addItemButton.addEventListener 'click', ->
- video.pause()
- # round time down to three decimal digits
- time = video.currentTime
- intTime = Math.floor(time)
- roundTime = intTime + Math.floor((time - intTime) * 1000) / 1000
- video.currentTime = roundTime
- $.ajax Routes.add_item_path(mediumId),
- type: 'GET'
- dataType: 'script'
- data: {
- time: video.currentTime
- }
- return
-
- # Event listener for addItem button
- addReferenceButton.addEventListener 'click', ->
- video.pause()
- # round time down to three decimal digits
- time = video.currentTime
- intTime = Math.floor(time)
- roundTime = intTime + Math.floor((time - intTime) * 1000) / 1000
- video.currentTime = roundTime
- $.ajax Routes.add_reference_path(mediumId),
- type: 'GET'
- dataType: 'script'
- data: {
- time: video.currentTime
- }
- return
-
- # Event listener for add screenshot button
- addScreenshotButton.addEventListener 'click', ->
- video.pause()
- # extract video screenshot from canvas
- context = canvas.getContext('2d')
- context.drawImage(video, 0, 0, canvas.width, canvas.height)
- base64image = canvas.toDataURL('image/png')
- # Get our file
- file = dataURLtoBlob(base64image)
- # Create new form data
- fd = new FormData
- # Append our Canvas image file to the form data
- fd.append 'image', file
- # And send it
- $.ajax Routes.add_screenshot_path(mediumId),
- type: 'POST'
- data: fd
- processData: false
- contentType: false
- return
-
- # after video metadata have been loaded, set up video length, volume bar and
- # seek bar
- video.addEventListener 'loadedmetadata', ->
- maxTime.innerHTML = secondsToTime(video.duration)
- volumeBar.value = video.volume
- volumeBar.style.backgroundImage = 'linear-gradient(to right,' +
- ' #2497E3, #2497E3 ' + video.volume*100 + '%, #ffffff ' +
- video.volume*100 + '%, #ffffff)'
- seekBar.value = 0
- canvas.width = Math.floor($(video).width())
- canvas.height = Math.floor($(video).height())
- return
-
- # Update the seek bar as the video plays
- video.addEventListener 'timeupdate', ->
- value = 100 / video.duration * video.currentTime
- seekBar.value = value
- seekBar.style.backgroundImage = 'linear-gradient(to right,' +
- ' #2497E3, #2497E3 ' + value + '%, #ffffff ' + value + '%, #ffffff)'
- currentTime.innerHTML = secondsToTime(video.currentTime)
- return
-
- # Pause the video when the seek handle is being dragged
- seekBar.addEventListener 'mousedown', ->
- video.dataset.paused = video.paused
- video.pause()
- return
-
- # Play the video when the seek handle is dropped
- seekBar.addEventListener 'mouseup', ->
- video.play() unless video.dataset.paused == 'true'
- return
-
- # Event listener for the volume bar
- volumeBar.addEventListener 'change', ->
- value = volumeBar.value
- video.volume = value
- return
-
- video.addEventListener 'volumechange', ->
- value = video.volume
- volumeBar.value = value
- volumeBar.style.backgroundImage = 'linear-gradient(to right,' +
- ' #2497E3, #2497E3 ' + value*100 + '%, #ffffff ' + value*100 + '%, #ffffff)'
- return
-
- return
diff --git a/app/assets/stylesheets/annotations.scss b/app/assets/stylesheets/annotations.scss
new file mode 100644
index 000000000..946dd05aa
--- /dev/null
+++ b/app/assets/stylesheets/annotations.scss
@@ -0,0 +1,209 @@
+/* buttons */
+#annotation-button {
+ position: absolute;
+ left: 51%;
+ font-size: 1.3rem;
+ padding: 2px 8px;
+
+ i {
+ &::before {
+ color: transparent;
+ background-clip: text;
+ background-image: radial-gradient(at bottom right, rgb(30, 82, 141) 0%, rgb(237, 152, 189) 100%);
+ }
+ }
+
+ transition: filter 60ms ease-in-out;
+
+ &:hover {
+ filter: drop-shadow(2px 3px 2px rgba(97, 114, 138, 0.3));
+ }
+}
+
+#annotations-toggle {
+ position: absolute;
+ display: flex;
+ left: 89%;
+
+ input:checked {
+ background-color: #2196F3; // TODO: outsource common video control colors
+ }
+}
+
+/* annotation modal */
+#annotation-modal {
+ .modal-header {
+ transition: background-color 200ms linear;
+ }
+
+ .modal-body {
+ display: flex;
+ }
+
+ .modal-footer {
+ margin-top: 40px;
+ padding-bottom: 0;
+ padding-right: 0;
+ }
+
+ .annotation-dialog-normal {
+ max-width: 560px;
+ }
+
+ .annotation-dialog-expanded {
+ max-width: 730px;
+ }
+
+ .annotation-content-normal {
+ width: 100%;
+ }
+
+ .annotation-content-expanded {
+ width: 70%;
+ }
+
+ #annotation-preview-section {
+ width: 30%;
+ }
+
+ #annotation-modal-preview {
+ word-wrap: break-word;
+ overflow-y: auto;
+ }
+
+ #annotation_comment {
+ height: 200px;
+ max-height: 300px;
+ }
+
+ #annotation_category_text {
+ width: 200px;
+ }
+
+ // adapted from https://stackoverflow.com/a/49065029/
+ section {
+ display: flex;
+ flex-direction: column;
+ // border: thin solid rgb(176, 176, 176);
+ }
+
+ // this column adapts to the other column with respect to its height
+ .column-adaptable {
+ flex-basis: 0;
+ flex-grow: 1;
+ }
+
+ .annotation-preview {
+ border-right: thin solid rgb(176, 176, 176);
+ margin-right: 8px;
+ padding-right: 8px;
+ }
+
+ .annotation-content-spacing {
+ padding-left: 8px;
+ }
+}
+
+.annotation-marker {
+ position: relative;
+ top: -12px;
+ width: 0;
+ height: 0;
+ display: flex;
+ cursor: pointer;
+
+ & i {
+ position: relative;
+ transition: filter 80ms ease-in-out;
+ filter: drop-shadow(1px 1px 2px rgba(0, 0, 0, 0.1));
+ font-size: 1.0rem;
+
+ &.annotation-marker-shown {
+ filter: drop-shadow(0px 0px 2px #fffd00) !important;
+ z-index: 100; // for markers on the same point in time
+ }
+
+ &:hover {
+ filter: drop-shadow(0px 0px 2px #fffd00);
+ }
+ }
+}
+
+#annotation-caption {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+}
+
+#annotation-infobar {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ flex-wrap: wrap;
+
+ padding: 0.6em;
+ box-shadow: 0px 2px 10px -2px rgba(0, 0, 0, 0.05);
+
+ color: #4b4b4b;
+ font-size: 0.85rem;
+ font-weight: bold;
+ letter-spacing: 1px;
+}
+
+#annotation-comment {
+ height: 100%;
+ overflow: overlay;
+ padding: 0.5em;
+
+ color: #4b4b4b;
+ font-size: 1rem;
+}
+
+#annotation-area-buttons {
+ display: flex;
+ justify-content: center;
+ padding: 0.4em 4.2em;
+
+ box-shadow: 0px -2px 10px -2px rgba(0, 0, 0, 0.05);
+
+ & i {
+ font-size: 1.1rem;
+ }
+}
+
+#annotation-color-picker {
+ input[type="radio"] {
+ display: none;
+
+ &:checked+label {
+ span {
+ transform: scale(1.25);
+ border: 2px solid #0000008a;
+ }
+ }
+ }
+
+ text-align: center;
+
+ label {
+ display: inline-block;
+ width: 25px;
+ height: 25px;
+ margin-right: 2px;
+ cursor: pointer;
+
+ &:hover {
+ span {
+ transform: scale(1.25);
+ }
+ }
+
+ span {
+ display: block;
+ width: 100%;
+ height: 100%;
+ border-radius: 50%;
+ transition: transform .1s ease-in-out;
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index 35f8c0d78..e8b97ca3e 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -27,9 +27,6 @@ $grid-breakpoints: (
lg: 992px,
xl: 1200px,
xxl: 1440px,
- xxxl: 1600px,
- xxxxl: 1920px,
- xxxxxl: 2160px
);
$container-max-widths: (
@@ -41,13 +38,11 @@ $container-max-widths: (
lg: 992px,
xl: 1200px,
xxl: 1440px,
- xxxl: 1600px,
- xxxxl: 1920px,
- xxxxxl: 2160px
);
/* override the !default vars with the values we set above */
+@import "annotations";
@import "bootstrap";
@import "rails_bootstrap_forms";
@import "chapters";
@@ -63,6 +58,7 @@ $container-max-widths: (
@import "tags";
@import "talks";
@import "thyme";
+@import "thyme_feedback";
@import "users";
@import "submissions";
@import "vertices";
@@ -302,4 +298,19 @@ a {
&:hover {
text-decoration: underline;
}
+}
+
+.toast {
+ background-color: white;
+}
+
+.small-width {
+ max-width: 900px;
+}
+
+.subtle-background {
+ flex: 1;
+ background: radial-gradient(circle at top right, transparent 10%, #fafdff 10%, #fafdff 20%, transparent 21%), radial-gradient(circle at left bottom, transparent 10%, #fafdff 10%, #fafdff 20%, transparent 21%), radial-gradient(circle at top left, transparent 10%, #fafdff 10%, #fafdff 20%, transparent 21%), radial-gradient(circle at right bottom, transparent 10%, #fafdff 10%, #fafdff 20%, transparent 21%), radial-gradient(circle at center, #fafdff 30%, transparent 31%);
+ background-size: 6em 6em;
+ background-color: #ffffff;
}
\ No newline at end of file
diff --git a/app/assets/stylesheets/comments.scss b/app/assets/stylesheets/comments.scss
index ee5fabfe1..1788da423 100644
--- a/app/assets/stylesheets/comments.scss
+++ b/app/assets/stylesheets/comments.scss
@@ -3,4 +3,21 @@
#toggleCommentPreviewWrapper {
padding-left: $form-check-padding-start + $form-check-input-width;
+}
+
+.form-control.commentForm {
+ border-color: #4173b4;
+ border-width: 1.5px;
+}
+
+.comment-field {
+ backdrop-filter: blur(8px);
+ background-color: rgba(255, 255, 255, 0.44);
+ padding: 6px !important;
+}
+
+.comment {
+ border: solid 1.5px #d2d2d2 !important;
+ border-radius: 4px;
+ box-shadow: rgba(0,0,0,0.025) 0px 2px 5px;
}
\ No newline at end of file
diff --git a/app/assets/stylesheets/feedback.scss b/app/assets/stylesheets/feedback.scss
new file mode 100644
index 000000000..7f14ad18c
--- /dev/null
+++ b/app/assets/stylesheets/feedback.scss
@@ -0,0 +1,11 @@
+#feedback-btn {
+ color: white;
+
+ &:focus {
+ box-shadow: none;
+ }
+
+ &:hover {
+ color: #ffc107;
+ }
+}
\ No newline at end of file
diff --git a/app/assets/stylesheets/thyme.scss b/app/assets/stylesheets/thyme.scss
index 6fbed3b35..97d6ab777 100644
--- a/app/assets/stylesheets/thyme.scss
+++ b/app/assets/stylesheets/thyme.scss
@@ -1,47 +1,45 @@
-
-#thyme-container {
- position: absolute;
- top: 0;
- width: 100%;
- height: 100%;
- font-family: 'Roboto';
+.thyme-container-layout {
+ position: absolute;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ font-family: 'Roboto';
}
#editor-container {
- height:100vh;
+ height: 100vh;
width: 100vw;
}
-#thyme {
- min-width: 100%;
- min-height: 100%;
- width: auto;
- height: auto;
- background: black;
+.thyme-generic {
+ min-width: 100%;
+ min-height: 100%;
+ width: auto;
+ height: auto;
+ background: black;
}
#hypervideo-container {
- font-size: 0;
position: relative;
background: white;
margin: 0;
}
#video {
- width: 82%;
- height: auto;
- display: inline-block;
+ display: block;
+ background: black;
}
#hypervideo-container figcaption {
position: absolute;
- right: 0; top: 0;
+ right: 0;
+ top: 0;
background: white;
width: 18%;
font-size: .8rem;
color: #666;
height: 100%;
- outline: 0;
+ outline: 0;
}
#hypervideo-container figcaption ol {
@@ -51,127 +49,176 @@
padding: 0;
}
-#video-container {
- position: relative;
-}
-
#video-controlBar {
- width: 82%;
- position: absolute;
- bottom: 0;
- left: 0;
- right: 0;
- padding: 5px;
- width: 82%;
- height: auto;
- background: lightgray;
- -webkit-user-select: none;
- -moz-user-select: none;
+ width: 82%;
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ padding: 5px;
+ width: 82%;
+ height: auto;
+ background: linear-gradient(120deg, hsl(214, 19%, 84%) 0%, hsl(207, 18%, 82%) 100%);
user-select: none;
}
#video-controls {
- position: relative;
- display: inline-block;
- height: 30px;
- width: 100%;
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ height: 2.5em;
+ padding: inherit;
}
#timeline {
- position: absolute;
- display: flex;
- width: 50%;
+ position: absolute;
+ display: flex;
+ width: 50%;
+ padding-left: inherit;
}
#current-time {
- font-size: 12px;
- padding: 4px;
+ font-size: 12px;
+ padding: 4px;
}
#seek-bar {
- flex-grow: 5;
- margin-top: 7px;
+ flex-grow: 5;
+ margin-top: 7px;
}
#max-time {
- font-size: 12px;
- padding: 4px;
+ font-size: 12px;
+ padding: 4px;
}
-#special-buttons {
- position: absolute;
- display: flex;
- left: 52%;
+.special-buttons-layout {
+ position: absolute;
+ display: flex;
+ left: 54%;
}
-.clickable
-{
+.clickable {
cursor: pointer;
}
#previous-chapter {
- margin-left: 10px;
+ margin-left: 10px;
}
-#volume-controls {
- position: absolute;
- display: flex;
- width: 10%;
- left: 68%;
+.volume-controls-layout {
+ position: absolute;
+ display: flex;
+ width: 10%;
+ left: 68%;
}
#speed-control {
- position: absolute;
- padding: 3px;
- font-size: 12px;
- left: 81%;
+ position: absolute;
+ padding: 3px;
+ font-size: 12px;
+ left: 79%;
}
#volume-bar {
- flex-grow: 2;
- min-width: 50%;
- margin-top: 7px;
- margin-left: 4px;
+ flex-grow: 2;
+ min-width: 50%;
+ margin-top: 7px;
+ margin-left: 4px;
+}
+
+/* The switch - the box around the slider */
+.switch {
+ position: relative;
+ display: inline-block;
+ width: 30px;
+ height: 17px;
+}
+
+/* Hide default HTML checkbox */
+.switch input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+/* The slider */
+.slider {
+ position: absolute;
+ cursor: pointer;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: #ccc;
+ transition: .4s;
+}
+
+.slider:before {
+ position: absolute;
+ content: "";
+ height: 13px;
+ width: 13px;
+ left: 2px;
+ bottom: 2px;
+ background-color: white;
+ transition: .4s;
+}
+
+input:focus+.slider {
+ box-shadow: 0 0 1px #2196F3;
+}
+
+input:checked+.slider:before {
+ transform: translateX(13px);
+}
+
+/* Rounded sliders */
+.slider.round {
+ border-radius: 17px;
+}
+
+.slider.round:before {
+ border-radius: 50%;
}
#size-buttons {
- position: absolute;
- right: 0%;
+ display: flex;
+ position: absolute;
+ right: 0%;
+ padding-right: inherit;
}
input[type=range] {
- -webkit-appearance: none;
- -moz-appearance: none;
- border-radius: 6px;
- height: 10px;
- background-image: linear-gradient(
- to right,
+ appearance: none;
+ border-radius: 6px;
+ height: 10px;
+ background-image: linear-gradient(to right,
#2497E3,
#2497E3 0%,
#ffffff 0%,
- #ffffff
- );
+ #ffffff);
}
input[type="range"]::-moz-range-track {
- border: none;
- background: none;
- outline: none;
+ border: none;
+ background: none;
+ outline: none;
}
input[type=range]:focus {
- outline: none;
- border: none;
+ outline: none;
+ border: none;
}
input[type=range]::-webkit-slider-thumb {
- -webkit-appearance: none !important;
- background-color: #2497E3;
- height: 16px;
- width: 16px;
- border-radius: 50%;
- border: 1px solid black;
+ -webkit-appearance: none !important;
+ background-color: #2497E3;
+ height: 16px;
+ width: 16px;
+ border-radius: 50%;
+ border: 1px solid black;
}
input[type=range]::-moz-range-thumb {
@@ -183,7 +230,7 @@ input[type=range]::-moz-range-thumb {
}
input[type=range]::-moz-focus-outer {
- border: 0;
+ border: 0;
}
figure {
@@ -210,38 +257,20 @@ figure {
height: calc(0.5*(100% - 6em - 4em));
border: 1px solid darkgray;
overflow-y: scroll;
- color: #484848;
+ color: #484848;
}
#metadata .current {
color: black;
- -webkit-animation: yellow-fade 10s ease-in;
- -moz-animation: yellow-fade 10s ease-in;
- -o-animation: yellow-fade 10s ease-in;
- animation: yellow-fade 10s ease-in;
+ animation: yellow-fade 10s ease-in;
+ animation: yellow-fade 10s ease-in;
}
-
-@-webkit-keyframes yellow-fade {
- from {
- background: gold;
- }
- to {
- background: #fff;
- }
-}
-@-moz-keyframes yellow-fade {
- from {
- background: gold;
- }
- to {
- background: #fff;
- }
-}
@keyframes yellow-fade {
from {
background: gold;
}
+
to {
background: #fff;
}
@@ -249,28 +278,29 @@ figure {
#metadata li a i {
- font-size: 1.5em;
+ font-size: 1.5em;
}
#metadata li {
- display: flex;
- justify-content: space-between;
+ display: flex;
+ justify-content: space-between;
position: relative;
border-bottom: 1px solid darkgrey;
- text-decoration: none;
- padding: 5px;
+ text-decoration: none;
+ padding: 5px;
}
#metadata li a {
- color: inherit;
+ color: inherit;
display: block;
text-decoration: none;
padding: 3px;
}
-.item:hover, .item:focus{
- background: #f0f0f0;
- cursor: pointer;
+.item:hover,
+.item:focus {
+ background: #f0f0f0;
+ cursor: pointer;
}
@@ -309,9 +339,9 @@ figure {
background: #e6e6e6;
border-left: 1px solid darkgray;
border-right: 1px solid darkgray;
- display: flex;
- justify-content: center;
- align-items: center;
+ display: flex;
+ justify-content: center;
+ align-items: center;
}
@@ -325,11 +355,9 @@ figure {
.replay {
position: absolute;
top: 0;
- right:0;
+ right: 0;
padding: 3px;
cursor: pointer;
- -webkit-user-select: none;
- -moz-user-select: none;
user-select: none;
}
@@ -337,7 +365,6 @@ figure {
background: lightgrey;
}
-:-webkit-full-screen::-webkit-backdrop
- {
- background-color: black;
- }
+:full-screen::backdrop {
+ background-color: black;
+}
\ No newline at end of file
diff --git a/app/assets/stylesheets/thyme_feedback.scss b/app/assets/stylesheets/thyme_feedback.scss
new file mode 100644
index 000000000..1bd1cb6ec
--- /dev/null
+++ b/app/assets/stylesheets/thyme_feedback.scss
@@ -0,0 +1,56 @@
+#thyme-feedback {
+ #timeline {
+ width: 55%;
+ }
+}
+
+#annotation-switches {
+ display: flex;
+ position: absolute;
+ left: 57%;
+
+ .form-switch .form-check-input {
+ border: none;
+
+ &:focus {
+ box-shadow: 0 0 0 0.25rem #ffffff4f;
+ background-image: url("data:image/svg+xml, ");
+ }
+
+ &:checked {
+ background-image: url("data:image/svg+xml, ");
+ }
+ }
+
+ // reflected from category.js
+ #annotation-category-note-switch:checked {
+ background-color: #f78f19;
+ }
+
+ #annotation-category-content-switch:checked {
+ background-color: #A333C8;
+ }
+
+ #annotation-category-presentation-switch:checked {
+ background-color: #2185D0;
+ }
+
+ #annotation-category-mistake-switch:checked {
+ background-color: #fc1461;
+ }
+}
+
+#feedback-special-buttons {
+ left: 73%;
+}
+
+#feedback-volume-controls {
+ left: 79%;
+}
+
+#heatmap {
+ position: relative;
+ width: 0;
+ height: 0;
+ pointer-events: none;
+}
\ No newline at end of file
diff --git a/app/controllers/annotations_controller.rb b/app/controllers/annotations_controller.rb
new file mode 100644
index 000000000..3b65c3a02
--- /dev/null
+++ b/app/controllers/annotations_controller.rb
@@ -0,0 +1,211 @@
+class AnnotationsController < ApplicationController
+ authorize_resource
+
+ def show
+ @annotation = Annotation.find(params[:id])
+ end
+
+ def new
+ @annotation = Annotation.new(category: :note, color: Annotation.colors[1])
+
+ @total_seconds = params[:total_seconds]
+ @medium_id = params[:medium_id]
+ @posted = false
+ @is_new_annotation = true
+
+ render :edit
+ end
+
+ def edit
+ @annotation = Annotation.find(params[:annotation_id])
+
+ # only allow editing, if the current user created the annotation
+ if @annotation.user_id != current_user.id
+ render json: false
+ return
+ end
+
+ @total_seconds = @annotation.timestamp.total_seconds
+ @medium_id = @annotation.medium_id
+ @posted = !@annotation.public_comment_id.nil?
+
+ # if this annotation has an associated commontator comment,
+ # we have to call the "comment_optional" method in order to get
+ # the text.
+ @annotation.comment = @annotation.comment_optional
+
+ @is_new_annotation = false
+ end
+
+ def create
+ @annotation = Annotation.new(annotation_params)
+
+ @annotation.user_id = current_user.id
+ @total_seconds = annotation_auxiliary_params[:total_seconds]
+ @annotation.timestamp = TimeStamp.new(total_seconds: @total_seconds)
+
+ return unless create_and_update_shared(@annotation)
+
+ @annotation.save
+ render :update
+ end
+
+ def update
+ @annotation = Annotation.find(params[:id])
+ @annotation.assign_attributes(annotation_params)
+
+ return unless create_and_update_shared(@annotation)
+
+ @annotation.save
+ end
+
+ def destroy
+ annotation = Annotation.find(params[:annotation_id])
+
+ # only the owner of the annotation is allowed to delete it
+ return unless annotation.user == current_user
+
+ # delete associated commontator comment
+ unless annotation.public_comment_id.nil?
+ commontator_comment = Commontator::Comment.find_by(id: annotation.public_comment_id)
+ commontator_comment.update(deleted_at: DateTime.now)
+ end
+
+ annotation.destroy
+
+ render json: []
+ end
+
+ def update_annotations
+ medium = Medium.find_by(id: params[:mediumId])
+
+ # Get the right annotations
+ annotations = if medium.annotations_visible?(current_user)
+ Annotation.where(medium: medium,
+ visible_for_teacher: true).or(
+ Annotation.where(medium: medium,
+ user: current_user)
+ )
+ else
+ Annotation.where(medium: medium,
+ user: current_user)
+ end
+
+ # If annotation is associated to a comment,
+ # the field "comment" is empty -> get it from the commontator comment
+ annotations.each do |a|
+ a.comment = a.comment_optional
+ end
+
+ # Convert to JSON (for easier hash operations)
+ annotations = annotations.as_json
+
+ # Filter attributes and add boolean "belongs_to_current_user".
+ annotations.each do |a|
+ a["belongs_to_current_user"] = (current_user.id == a["user_id"])
+ a.slice!("belongs_to_current_user", "category", "color", "comment",
+ "id", "subcategory", "timestamp")
+ end
+
+ render json: annotations
+ end
+
+ def num_nearby_posted_mistake_annotations
+ # the time (!) radius in which annotation are considered as "nearby"
+ radius = 60
+ timestamp = params[:timestamp].to_i
+ annotations = Annotation.where(medium: params[:mediumId], category: "mistake").commented
+ counter = annotations.to_a.count { |annotation| annotation.nearby?(timestamp, radius) }
+ render json: counter
+ end
+
+ def current_ability
+ @current_ability ||= AnnotationAbility.new(current_user)
+ end
+
+ private
+
+ def annotation_params
+ params.require(:annotation).permit(
+ :category, :color, :comment, :medium_id, :subcategory, :visible_for_teacher
+ )
+ end
+
+ def annotation_auxiliary_params
+ params.require(:annotation).permit(
+ :total_seconds, :post_as_comment
+ )
+ end
+
+ # TODO: Frontend should not pass color hex strings, instead pass the respective
+ # color keys, e.g. 14, see annotation.rb color_map for lookup.
+ def valid_color?(color)
+ Annotation.colors.value?(color)
+ # if you want to allow any color (not just the given selection
+ # in Annotation.colors), use the following regex check:
+ # color&.match?(/\A#([0-9]|[A-F]){6}\z/)
+ end
+
+ def valid_time?(annotation)
+ time = annotation.timestamp.total_seconds
+ time >= 0 and time <= annotation.medium.video["duration"]
+ end
+
+ # checks that the subcategory is non-nil if the category is "content" and
+ # resets the subcategory to "nil" if the selected category isn't "content"
+ def subcategory_nil(annotation)
+ return if (annotation.category_for_database == Annotation.categories[:content]) &&
+ annotation.subcategory.nil?
+
+ if annotation.category_for_database != Annotation.categories[:content]
+ annotation.subcategory = nil
+ end
+ true
+ end
+
+ # common code for the create and update method
+ def create_and_update_shared(annotation)
+ valid_color?(annotation.color) &&
+ valid_time?(annotation) &&
+ subcategory_nil(annotation) &&
+ commontator_comment(annotation)
+ end
+
+ # Run all the Commontator::Comment related code here.
+ def commontator_comment(annotation)
+ public_comment_id = annotation.public_comment_id
+
+ # return true (success) if checkbox "post_as_comment" is not checked
+ # and if there is no comment to update
+ return true if annotation_auxiliary_params[:post_as_comment] != "1" &&
+ public_comment_id.nil?
+
+ body = annotation_params[:comment]
+
+ if public_comment_id.nil? # comment doesn't exist yet -> create one
+ medium = annotation.medium
+ comment = Commontator::Comment.new(
+ thread: medium.commontator_thread,
+ creator: current_user,
+ body: body,
+ annotation: annotation
+ )
+ else # comment already exists -> update it
+ comment = Commontator::Comment.find_by(id: public_comment_id)
+ comment.assign_attributes(editor: current_user,
+ body: body)
+ end
+
+ # if the same comment already exists, the db will trigger a rollback
+ # -> print error message in that case
+ if !comment.save && comment.errors.of_kind?(:body, :taken)
+ render :duplicate_comment
+ return
+ end
+
+ # delete comment as it is already saved in the commontator comment model
+ annotation.comment = nil
+
+ annotation.public_comment_id = comment.id
+ end
+end
diff --git a/app/controllers/answers_controller.rb b/app/controllers/answers_controller.rb
index 937273189..88d24404c 100644
--- a/app/controllers/answers_controller.rb
+++ b/app/controllers/answers_controller.rb
@@ -1,6 +1,6 @@
# AnswersController
class AnswersController < ApplicationController
- before_action :set_answer, except: [:new, :create, :update_answer_box]
+ before_action :set_answer, except: [:new, :create]
authorize_resource except: [:new, :create]
def current_ability
@@ -34,9 +34,8 @@ def destroy
@success = true
end
- def update_answer_box
- @answer_id = params[:answer_id].to_i
- @value = params[:value] == "true"
+ def cancel_edit
+ I18n.locale = @answer.question&.locale_with_inheritance
end
private
diff --git a/app/controllers/commontator/comments_controller.rb b/app/controllers/commontator/comments_controller.rb
index 31058b3da..43d668ecb 100644
--- a/app/controllers/commontator/comments_controller.rb
+++ b/app/controllers/commontator/comments_controller.rb
@@ -71,6 +71,7 @@ def create
Commontator::Subscription.comment_created(@comment)
# The next line constitutes a customization of the original controller
update_unread_status
+ activate_unread_comments_icon_if_necessary
@commontator_page = @commontator_thread.new_comment_page(
@comment.parent_id, @commontator_show_all
@@ -155,7 +156,7 @@ def upvote
# PUT /comments/1/downvote
def downvote
- security_transgression_unless(@comment.can_be_voted_on_by?(@commontator_user) && \
+ security_transgression_unless(@comment.can_be_voted_on_by?(@commontator_user) &&
@comment.thread.config.comment_voting.to_sym == :ld)
@comment.downvote_from(@commontator_user)
@@ -194,9 +195,14 @@ def subscribe_mentioned
end
end
- # This method ensures that the unread_comments flag is updated
- # for users affected by the creation of a newly created comment
- # It constitues a customization
+ # Updates the unread_comments flag for users subscribed to the current thread.
+ # This method should only be called when a new comment was created.
+ #
+ # The originator of the comment does not get the flag set since that user
+ # already knows about the comment; that user has just created it after all.
+ #
+ # (This is a customization of the original controller provided
+ # by the commontator gem.)
def update_unread_status
medium = @commontator_thread.commontable
return unless medium.released.in?(["all", "users", "subscribers"])
@@ -205,22 +211,35 @@ def update_unread_status
relevant_users.where.not(id: current_user.id)
.where(unread_comments: false)
.update(unread_comments: true)
-
- # make sure that the thread associated to this comment is marked as read
- # by the comment creator (unless some other user posted a comment in it
- # that has not yet been read)
- @reader = Reader.find_or_create_by(user: current_user,
- thread: @commontator_thread)
- if unseen_comments?
- @update_icon = true
- return
- end
- @reader.touch
end
- def unseen_comments?
+ # Might activate the flag used in the view to indicate unread comments.
+ # This method should only be called when a new comment was created.
+ # The flag is activated if the current user has not seen all comments
+ # in the thread in which the new comment was created.
+ #
+ # The flag might only be activated, not deactivated since the checks
+ # performed here are not sufficient to determine whether a user has
+ # any unread comments (including those in possibly other threads).
+ #
+ # This method was introduced for one specific edge case:
+ # When the current user A has just created a new comment in a thread,
+ # but in the meantime, another user B has created a comment in the same
+ # thread. User A will not be informed immediately about the new comment
+ # by B since we don't have websockets implemented. Instead, A will be
+ # informed by a visual indicator as soon as A has posted their own comment.
+ #
+ # (This is a customization of the original controller provided
+ # by the commontator gem.)
+ def activate_unread_comments_icon_if_necessary
+ reader = Reader.find_by(user: current_user, thread: @commontator_thread)
+ @update_icon = true if unseen_comments_in_current_thread?(reader)
+ end
+
+ def unseen_comments_in_current_thread?(reader = nil)
@commontator_thread.comments.any? do |c|
- c.creator != current_user && c.created_at > @reader.updated_at
+ not_marked_as_read_in_reader = reader.nil? || c.created_at > reader.updated_at
+ c.creator != current_user && not_marked_as_read_in_reader
end
end
end
diff --git a/app/controllers/courses_controller.rb b/app/controllers/courses_controller.rb
index bfe9704a9..40b51e207 100644
--- a/app/controllers/courses_controller.rb
+++ b/app/controllers/courses_controller.rb
@@ -94,13 +94,12 @@ def set_course_admin
end
def course_params
- params.require(:course).permit(:title, :short_title, :organizational,
- :organizational_concept, :locale,
- :term_independent, :image,
- tag_ids: [],
- preceding_course_ids: [],
- editor_ids: [],
- division_ids: [])
+ allowed_params = [:title, :short_title, :organizational,
+ :organizational_concept, :locale,
+ :term_independent, :image,
+ { tag_ids: [], preceding_course_ids: [], division_ids: [] }]
+ allowed_params.push(editor_ids: []) if current_user.admin?
+ params.require(:course).permit(allowed_params)
end
def tag_params
diff --git a/app/controllers/erdbeere_controller.rb b/app/controllers/erdbeere_controller.rb
index 23c78601e..f47ec1daa 100644
--- a/app/controllers/erdbeere_controller.rb
+++ b/app/controllers/erdbeere_controller.rb
@@ -8,7 +8,7 @@ def current_ability
end
def show_example
- response = Faraday.get(ENV.fetch("ERDBEERE_API", nil) + "/examples/#{params[:id]}")
+ response = Faraday.get(ENV.fetch("ERDBEERE_API") + "/examples/#{params[:id]}")
@content = if response.status == 200
JSON.parse(response.body)["embedded_html"]
else
@@ -17,7 +17,7 @@ def show_example
end
def show_property
- response = Faraday.get(ENV.fetch("ERDBEERE_API", nil) + "/properties/#{params[:id]}")
+ response = Faraday.get(ENV.fetch("ERDBEERE_API") + "/properties/#{params[:id]}")
@content = if response.status == 200
JSON.parse(response.body)["embedded_html"]
@@ -28,7 +28,7 @@ def show_property
def show_structure
params[:id]
- response = Faraday.get(ENV.fetch("ERDBEERE_API", nil) + "/structures/#{params[:id]}")
+ response = Faraday.get(ENV.fetch("ERDBEERE_API") + "/structures/#{params[:id]}")
@content = if response.status == 200
JSON.parse(response.body)["embedded_html"]
else
@@ -51,7 +51,7 @@ def cancel_edit_tags
def display_info
@id = params[:id]
@sort = params[:sort]
- response = Faraday.get(ENV.fetch("ERDBEERE_API", nil) +
+ response = Faraday.get(ENV.fetch("ERDBEERE_API") +
"/#{@sort.downcase.pluralize}/#{@id}/view_info")
@content = JSON.parse(response.body)
if response.status != 200
@@ -87,7 +87,7 @@ def update_tags
end
def fill_realizations_select
- response = Faraday.get("#{ENV.fetch("ERDBEERE_API", nil)}/structures/")
+ response = Faraday.get("#{ENV.fetch("ERDBEERE_API")}/structures/")
@tag = Tag.find_by(id: params[:id])
hash = JSON.parse(response.body)
@structures = hash["data"].map do |d|
@@ -102,7 +102,7 @@ def fill_realizations_select
end
def find_example
- response = Faraday.get("#{ENV.fetch("ERDBEERE_API", nil)}/find?#{find_params.to_query}")
+ response = Faraday.get("#{ENV.fetch("ERDBEERE_API")}/find?#{find_params.to_query}")
@content = if response.status == 200
JSON.parse(response.body)["embedded_html"]
else
diff --git a/app/controllers/feedbacks_controller.rb b/app/controllers/feedbacks_controller.rb
new file mode 100644
index 000000000..9ae31a957
--- /dev/null
+++ b/app/controllers/feedbacks_controller.rb
@@ -0,0 +1,21 @@
+class FeedbacksController < ApplicationController
+ authorize_resource except: [:create]
+
+ def create
+ feedback = Feedback.new(feedback_params)
+ feedback.user_id = current_user.id
+ @feedback_success = feedback.save
+
+ if @feedback_success
+ FeedbackMailer.with(feedback: feedback).new_user_feedback_email.deliver_later
+ end
+
+ respond_to(&:js)
+ end
+
+ private
+
+ def feedback_params
+ params.require(:feedback).permit(:title, :feedback, :can_contact)
+ end
+end
diff --git a/app/controllers/lectures_controller.rb b/app/controllers/lectures_controller.rb
index d108fbbc0..10682539f 100644
--- a/app/controllers/lectures_controller.rb
+++ b/app/controllers/lectures_controller.rb
@@ -53,6 +53,7 @@ def new
# info to the lecture
@lecture.course = Course.find_by(id: params[:course])
I18n.locale = @lecture.course.locale
+ @lecture.annotations_status = 0
end
def edit
@@ -90,6 +91,8 @@ def create
end
def update
+ return unless @lecture.valid_annotations_status?
+
editor_ids = lecture_params[:editor_ids]
unless editor_ids.nil?
# removes the empty String "" in the NEW array of editor ids
@@ -217,7 +220,7 @@ def edit_structures
def search_examples
if @lecture.structure_ids.any?
- response = Faraday.get("#{ENV.fetch("ERDBEERE_API", nil)}/search")
+ response = Faraday.get("#{ENV.fetch("ERDBEERE_API")}/search")
@form = JSON.parse(response.body)["embedded_html"]
# rubocop:disable Style/StringConcatenation
@form.gsub!("token_placeholder",
@@ -232,6 +235,12 @@ def search_examples
def close_comments
@lecture.close_comments!(current_user)
+ # disable annotation button
+ @lecture.update(annotations_status: 0)
+ @lecture.media.update(annotations_status: -1)
+ @lecture.lessons.each do |lesson|
+ lesson.media.update(annotations_status: -1)
+ end
redirect_to edit_lecture_path(@lecture)
end
@@ -313,7 +322,8 @@ def lecture_params
:organizational_concept, :muesli,
:organizational_on_top, :disable_teacher_display,
:content_mode, :passphrase, :sort, :comments_disabled,
- :submission_max_team_size, :submission_grace_period]
+ :submission_max_team_size, :submission_grace_period,
+ :annotations_status]
if action_name == "update" && current_user.can_update_personell?(@lecture)
allowed_params.push(:teacher_id, { editor_ids: [] })
end
@@ -402,7 +412,7 @@ def eager_load_stuff
def set_erdbeere_data
@structure_ids = @lecture.structure_ids
- response = Faraday.get("#{ENV.fetch("ERDBEERE_API", nil)}/structures")
+ response = Faraday.get("#{ENV.fetch("ERDBEERE_API")}/structures")
response_hash = if response.status == 200
JSON.parse(response.body)
else
diff --git a/app/controllers/main_controller.rb b/app/controllers/main_controller.rb
index 8fa45237c..c7e8d469b 100644
--- a/app/controllers/main_controller.rb
+++ b/app/controllers/main_controller.rb
@@ -26,7 +26,7 @@ def sponsors
end
def comments
- @media_comments = current_user.media_latest_comments
+ @media_comments = current_user.subscribed_media_with_latest_comments_not_by_creator
@media_comments.select! do |m|
(Reader.find_by(user: current_user, thread: m[:thread])
&.updated_at || 1000.years.ago) < m[:latest_comment].created_at &&
diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb
index cfb78dd6e..2e8bf90fd 100644
--- a/app/controllers/media_controller.rb
+++ b/app/controllers/media_controller.rb
@@ -59,6 +59,9 @@ def edit
def create
@medium = Medium.new(medium_params)
+
+ return unless @medium.valid_annotations_status?
+
@medium.locale = @medium.teachable&.locale
@medium.editors = [current_user]
@medium.tags = @medium.teachable.tags if @medium.teachable.instance_of?(::Lesson)
@@ -106,6 +109,8 @@ def update
@errors = @medium.errors
return unless @errors.empty?
+ return unless @medium.valid_annotations_status?
+
# make sure the medium is touched
# (it will not be touched automatically in some cases (e.g. if you only
# update the associated tags), causing trouble for caching)
@@ -228,21 +233,6 @@ def search
results = search.results
@total = search.total
- # in the case of a search with tag_operator 'or', we
- # execute two searches and merge the results, where media
- # with the selected tags are now shown at the front of the list
- if (search_params[:tag_operator] == "or") \
- && (search_params[:all_tags] == "0") \
- && (search_params[:fulltext].size >= 2)
- params["search"]["all_tags"] = "1"
- search_no_tags = Medium.search_by(search_params, params[:page])
- search_no_tags.execute
- results_no_tags = search_no_tags.results
- results = (results + results_no_tags).uniq
- @total = results.size
- params["search"]["all_tags"] = "0"
- end
-
if filter_media
search_arel = Medium.where(id: results.pluck(:id))
visible_search_results = current_user.filter_visible_media(search_arel)
@@ -456,7 +446,7 @@ def statistics
def show_comments
commontator_thread_show(@medium)
- render layout: "application_no_sidebar"
+ render layout: "application_no_sidebar_with_background"
end
def cancel_publication
@@ -524,6 +514,19 @@ def fill_reassign_modal
@no_rights = params[:rights] == "none"
end
+ def check_annotation_visibility
+ medium = Medium.find_by(id: params[:id])
+ isPermitted = medium.annotations_visible?(current_user) # rubocop:todo Naming/VariableName
+ render json: isPermitted # rubocop:todo Naming/VariableName
+ end
+
+ # Renders the feedback player. Do not confuse with the feedback button
+ # which has nothing to do with the thyme player(s).
+ def feedback
+ I18n.locale = @medium.locale_with_inheritance
+ render layout: "feedback"
+ end
+
private
def medium_params
@@ -534,6 +537,7 @@ def medium_params
:teachable_type, :teachable_id,
:released, :text, :locale,
:content, :boost,
+ :annotations_status,
editor_ids: [],
tag_ids: [],
linked_medium_ids: [])
@@ -636,7 +640,7 @@ def search_results
unless current_user.admin || @lecture.edited_by?(current_user)
lecture_tags = @lecture.tags_including_media_tags
search_results.reject! do |m|
- m.teachable_type == "Course" && !m.tags.intersect?(lecture_tags)
+ m.teachable_type == "Course" && !m.tags.to_a.intersect?(lecture_tags)
end
end
end
diff --git a/app/controllers/readers_controller.rb b/app/controllers/readers_controller.rb
index f276f2989..ed0a41de6 100644
--- a/app/controllers/readers_controller.rb
+++ b/app/controllers/readers_controller.rb
@@ -9,7 +9,7 @@ def update
@reader = Reader.find_or_create_by(user: current_user,
thread: @thread)
@reader.touch
- @anything_left = current_user.media_latest_comments.any? do |m|
+ @anything_left = current_user.subscribed_media_with_latest_comments_not_by_creator.any? do |m|
(Reader.find_by(user: current_user, thread: m[:thread])
&.updated_at || 1000.years.ago) < m[:latest_comment].created_at
end
@@ -21,9 +21,8 @@ def update_all
.map(&:commontator_thread)
existing_readers = Reader.where(user: current_user, thread: threads)
missing_thread_ids = threads.map(&:id) - existing_readers.pluck(:thread_id)
- new_readers = []
- missing_thread_ids.each do |t|
- new_readers << Reader.new(thread_id: t, user: current_user)
+ new_readers = missing_thread_ids.map do |t|
+ Reader.new(thread_id: t, user: current_user)
end
Reader.import new_readers
Reader.where(user: current_user, thread: threads).touch_all
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index bc9152b78..939f2d56b 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -9,12 +9,12 @@ def verify_captcha
return true unless ENV["USE_CAPTCHA_SERVICE"]
begin
- uri = URI.parse(ENV.fetch("CAPTCHA_VERIFY_URL", nil))
+ uri = URI.parse(ENV.fetch("CAPTCHA_VERIFY_URL"))
data = { message: params["frc-captcha-solution"],
- application_token: ENV.fetch("CAPTCHA_APPLICATION_TOKEN", nil) }
+ application_token: ENV.fetch("CAPTCHA_APPLICATION_TOKEN") }
header = { "Content-Type": "text/json" }
http = Net::HTTP.new(uri.host, uri.port)
- http.use_ssl = true if ENV["CAPTCHA_VERIFY_URL"].include?("https")
+ http.use_ssl = true if ENV.fetch("CAPTCHA_VERIFY_URL").include?("https")
request = Net::HTTP::Post.new(uri.request_uri, header)
request.body = data.to_json
@@ -70,9 +70,9 @@ def after_sign_up_path_for(_resource)
private
def check_registration_limit
- timeframe = ((ENV["MAMPF_REGISTRATION_TIMEFRAME"] || 15).to_i.minutes.ago..)
+ timeframe = (ENV.fetch("MAMPF_REGISTRATION_TIMEFRAME", 15).to_i.minutes.ago..)
num_new_registrations = User.where(confirmed_at: nil, created_at: timeframe).count
- max_registrations = (ENV["MAMPF_MAX_REGISTRATION_PER_TIMEFRAME"] || 40).to_i
+ max_registrations = ENV.fetch("MAMPF_MAX_REGISTRATION_PER_TIMEFRAME", 40).to_i
return if num_new_registrations <= max_registrations
# Current number of new registrations is too high
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 50f6b333b..f267191e3 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -16,7 +16,7 @@ def current_lecture
def host
if Rails.env.production?
# rubocop:disable Style/StringConcatenation
- ENV.fetch("MEDIA_SERVER", nil) + "/" + ENV.fetch("INSTANCE_NAME", nil)
+ ENV.fetch("MEDIA_SERVER") + "/" + ENV.fetch("INSTANCE_NAME")
# rubocop:enable Style/StringConcatenation
else
""
@@ -29,7 +29,7 @@ def host
# the actual media server.
# This is used for the download buttons for videos and manuscripts.
def download_host
- Rails.env.production? ? ENV.fetch("DOWNLOAD_LOCATION", nil) : ""
+ Rails.env.production? ? ENV.fetch("DOWNLOAD_LOCATION") : ""
end
# Returns the full title on a per-page basis.
diff --git a/app/helpers/media_helper.rb b/app/helpers/media_helper.rb
index 6cc9c5140..fe432a51f 100644
--- a/app/helpers/media_helper.rb
+++ b/app/helpers/media_helper.rb
@@ -147,6 +147,6 @@ def edit_or_show_medium_path(medium)
def external_link_description_not_empty(medium)
# Uses link display name if not empty, otherwise falls back to the
# link url itself.
- (medium.external_link_description.presence || medium.external_reference_link)
+ medium.external_link_description.presence || medium.external_reference_link
end
end
diff --git a/app/mailers/exception_handler/exception_mailer.rb b/app/mailers/exception_handler/exception_mailer.rb
index 84db08e96..f56a25431 100644
--- a/app/mailers/exception_handler/exception_mailer.rb
+++ b/app/mailers/exception_handler/exception_mailer.rb
@@ -5,7 +5,7 @@ class ExceptionMailer < ApplicationMailer
# Defaults
default subject: I18n.t("exception.exception",
- host: ENV.fetch("URL_HOST", nil))
+ host: ENV.fetch("URL_HOST"))
default from: ExceptionHandler.config.email
default template_path: "exception_handler/mailers"
# => http://stackoverflow.com/a/18579046/1143732
diff --git a/app/mailers/feedback_mailer.rb b/app/mailers/feedback_mailer.rb
new file mode 100644
index 000000000..152b1378b
--- /dev/null
+++ b/app/mailers/feedback_mailer.rb
@@ -0,0 +1,15 @@
+class FeedbackMailer < ApplicationMailer
+ default from: DefaultSetting::FEEDBACK_EMAIL
+ layout false
+
+ # Mail to the MaMpf developers including the new feedback of a user.
+ def new_user_feedback_email
+ @feedback = params[:feedback]
+ reply_to_mail = @feedback.can_contact ? @feedback.user.email : ""
+ subject = "Feedback: #{@feedback.title}"
+ mail(to: DefaultSetting::FEEDBACK_EMAIL,
+ subject: subject,
+ content_type: "text/plain",
+ reply_to: reply_to_mail)
+ end
+end
diff --git a/app/models/annotation.rb b/app/models/annotation.rb
new file mode 100644
index 000000000..b15b36052
--- /dev/null
+++ b/app/models/annotation.rb
@@ -0,0 +1,49 @@
+class Annotation < ApplicationRecord
+ belongs_to :medium
+ belongs_to :user
+ belongs_to :public_comment, class_name: "Commontator::Comment",
+ optional: true
+
+ scope :commented, -> { where.not(public_comment_id: nil) }
+
+ # the timestamp for the annotation position is serialized as text in the db
+ serialize :timestamp, TimeStamp
+
+ enum category: { note: 0, content: 1, mistake: 2, presentation: 3 }
+ enum subcategory: { definition: 0, argument: 1, strategy: 2 }
+
+ # If the annotation has an associated commontator comment, its comment will
+ # be saved in the commontator comment. While calling annotation.comment returns
+ # nil in this case, this method pulls out the actual comment from the commontator
+ # comment.
+ def comment_optional
+ return comment if public_comment_id.nil?
+
+ Commontator::Comment.find_by(id: public_comment_id).body
+ end
+
+ def nearby?(other_timestamp, radius)
+ (timestamp.total_seconds - other_timestamp).abs < radius
+ end
+
+ def self.colors
+ # Colors must have 6 digits and be capitalized (!)
+ {
+ 1 => "#DB2828",
+ 2 => "#F2711C",
+ 3 => "#FBBD08",
+ 4 => "#B5CC18",
+ 5 => "#21BA45",
+ 6 => "#00B5AD",
+ 7 => "#2185D0",
+ 8 => "#6435C9",
+ 9 => "#A333C8",
+ 10 => "#E03997",
+ 11 => "#D05D41",
+ 12 => "#924129",
+ 13 => "#444444",
+ 14 => "#999999",
+ 15 => "#EEEEEE"
+ }
+ end
+end
diff --git a/app/models/feedback.rb b/app/models/feedback.rb
new file mode 100644
index 000000000..2cc0a0c6b
--- /dev/null
+++ b/app/models/feedback.rb
@@ -0,0 +1,10 @@
+# Feedback from users regarding MaMpf itself.
+class Feedback < ApplicationRecord
+ belongs_to :user
+
+ BODY_MIN_LENGTH = 10
+ BODY_MAX_LENGTH = 10_000
+ validates :feedback, length: { minimum: BODY_MIN_LENGTH,
+ maximum: BODY_MAX_LENGTH },
+ allow_blank: false
+end
diff --git a/app/models/lecture.rb b/app/models/lecture.rb
index 8d5c1d353..f12f06769 100644
--- a/app/models/lecture.rb
+++ b/app/models/lecture.rb
@@ -840,6 +840,10 @@ def stale?
older_than?(1.year)
end
+ def valid_annotations_status?
+ [-1, 1].include?(annotations_status)
+ end
+
private
# used for after save callback
diff --git a/app/models/manuscript.rb b/app/models/manuscript.rb
index 3316d5fb7..13317cb1d 100644
--- a/app/models/manuscript.rb
+++ b/app/models/manuscript.rb
@@ -148,19 +148,16 @@ def create_or_update_chapter_items!
attrs = [:medium_id, :pdf_destination, :section_id, :sort, :page, :description, :ref_number,
:position, :quarantine]
item_details = items.pluck(*attrs).map { |i| attrs.zip(i).to_h }
- contents = []
- @chapters.each do |c|
- contents.push(
- { medium_id: @medium.id,
- pdf_destination: c["destination"],
- section_id: nil,
- sort: "chapter",
- page: c["page"].to_i,
- description: c["description"],
- ref_number: c["label"],
- position: nil,
- quarantine: nil }
- )
+ contents = @chapters.map do |c|
+ { medium_id: @medium.id,
+ pdf_destination: c["destination"],
+ section_id: nil,
+ sort: "chapter",
+ page: c["page"].to_i,
+ description: c["description"],
+ ref_number: c["label"],
+ position: nil,
+ quarantine: nil }
end
create_or_update_items!(contents, item_details, item_destinations,
item_id_map)
@@ -176,21 +173,18 @@ def create_or_update_section_items!
attrs = [:medium_id, :pdf_destination, :section_id, :sort, :page, :description, :ref_number,
:position, :quarantine]
item_details = items.pluck(*attrs).map { |i| attrs.zip(i).to_h }
- contents = []
# NOTE: that sections get a position -1 in order to place them ahead
# of all content items within themseleves in #script_items_by_position
- @sections.each do |s|
- contents.push(
- { medium_id: @medium.id,
- pdf_destination: s["destination"],
- section_id: s["mampf_section"].id,
- sort: "section",
- page: s["page"].to_i,
- description: s["description"],
- ref_number: s["label"],
- position: -1,
- quarantine: nil }
- )
+ contents = @sections.map do |s|
+ { medium_id: @medium.id,
+ pdf_destination: s["destination"],
+ section_id: s["mampf_section"].id,
+ sort: "section",
+ page: s["page"].to_i,
+ description: s["description"],
+ ref_number: s["label"],
+ position: -1,
+ quarantine: nil }
end
create_or_update_items!(contents, item_details, item_destinations,
item_id_map)
@@ -208,22 +202,19 @@ def create_or_update_content_items!(filter_boxes)
attrs = [:medium_id, :pdf_destination, :section_id, :sort, :page, :description, :ref_number,
:position, :hidden, :quarantine]
item_details = items.pluck(*attrs).map { |i| attrs.zip(i).to_h }
- contents = []
- @content.each do |c|
- contents.push(
- { medium_id: @medium.id,
- pdf_destination: c["destination"],
- section_id: @sections.find do |s|
- c["section"] == s["section"]
- end ["mampf_section"]&.id,
- sort: Item.internal_sort(c["sort"]),
- page: c["page"].to_i,
- description: c["description"],
- ref_number: c["label"],
- position: c["counter"],
- hidden: filter_boxes[c["counter"]].third == false,
- quarantine: nil }
- )
+ contents = @content.map do |c|
+ { medium_id: @medium.id,
+ pdf_destination: c["destination"],
+ section_id: @sections.find do |s|
+ c["section"] == s["section"]
+ end ["mampf_section"]&.id,
+ sort: Item.internal_sort(c["sort"]),
+ page: c["page"].to_i,
+ description: c["description"],
+ ref_number: c["label"],
+ position: c["counter"],
+ hidden: filter_boxes[c["counter"]].third == false,
+ quarantine: nil }
end
create_or_update_items!(contents, item_details, item_destinations,
item_id_map)
diff --git a/app/models/medium.rb b/app/models/medium.rb
index 0fe6faaa5..f0f079536 100644
--- a/app/models/medium.rb
+++ b/app/models/medium.rb
@@ -301,9 +301,6 @@ def self.search_by(search_params, _page)
search_params[:all_terms] = "1" if search_params[:all_terms].blank?
search_params[:all_teachers] = "1" if search_params[:all_teachers].blank?
search_params[:term_ids].push("0") if search_params[:term_ids].present?
- if search_params[:all_tags] == "1" && search_params[:tag_operator] == "and"
- search_params[:tag_ids] = Tag.pluck(:id)
- end
user = User.find_by(id: search_params[:user_id])
search = Sunspot.new_search(Medium)
search.build do
@@ -336,15 +333,12 @@ def self.search_by(search_params, _page)
with(:release_state, search_params[:access])
end
end
- if !search_params[:all_tags] == "1" &&
- !search_params[:tag_operator] == "or" && (search_params[:tag_ids])
- if search_params[:tag_operator] == "or" || search_params[:all_tags] == "1"
- search.build do
- with(:tag_ids).any_of(search_params[:tag_ids])
- end
- else
- search.build do
+ if search_params[:all_tags] == "0" && search_params[:tag_ids].any?
+ search.build do
+ if search_params[:tag_operator] == "and"
with(:tag_ids).all_of(search_params[:tag_ids])
+ else
+ with(:tag_ids).any_of(search_params[:tag_ids])
end
end
end
@@ -1093,6 +1087,26 @@ def subscribed_users
Lecture.find_by(id: teachable.lecture_id).user_ids
end
+ # Returns either the annotations status (1 = activated, 0 = deactivated)
+ # of this medium or the annotations status of the associated lecture
+ # if "inherit from lecture" was selected (i.e. if the annotations status of
+ # this medium is -1).
+ def get_annotations_status # rubocop:todo Naming/AccessorMethodName
+ return lecture.annotations_status if annotations_status == -1 && lecture.present?
+
+ annotations_status
+ end
+
+ def annotations_visible?(user)
+ is_teacher = edited_with_inheritance_by?(user)
+ is_activated = (get_annotations_status == 1)
+ is_teacher && is_activated
+ end
+
+ def valid_annotations_status?
+ [-1, 0, 1].include?(annotations_status)
+ end
+
private
# media of type kaviar associated to a lesson and script do not require
diff --git a/app/models/medium_publisher.rb b/app/models/medium_publisher.rb
index 3607359e3..ea13cb70f 100644
--- a/app/models/medium_publisher.rb
+++ b/app/models/medium_publisher.rb
@@ -128,13 +128,12 @@ def realize_optional_stuff!
# to the medium's teachable's media_scope
def create_notifications!
@medium.teachable&.media_scope&.touch
- notifications = []
@medium.teachable.media_scope.users.touch_all
- @medium.teachable.media_scope.users.each do |u|
- notifications << Notification.new(recipient: u,
- notifiable_id: @medium.id,
- notifiable_type: "Medium",
- action: "create")
+ notifications = @medium.teachable.media_scope.users.map do |u|
+ Notification.new(recipient: u,
+ notifiable_id: @medium.id,
+ notifiable_type: "Medium",
+ action: "create")
end
Notification.import notifications
end
diff --git a/app/models/notion.rb b/app/models/notion.rb
index 15d339ef9..fbc1cd4e7 100644
--- a/app/models/notion.rb
+++ b/app/models/notion.rb
@@ -2,7 +2,7 @@ class Notion < ApplicationRecord
belongs_to :tag, optional: true, touch: true
belongs_to :aliased_tag, class_name: "Tag", optional: true, touch: true
- validates :title, uniqueness: { scope: :locale } # rubocop:todo Rails/UniqueValidationWithoutIndex
+ validates :title, uniqueness: { scope: :locale }
validates :title, presence: true
validate :presence_of_tag, if: :persisted?
diff --git a/app/models/quiz.rb b/app/models/quiz.rb
index 4d03afe8c..f4935b447 100644
--- a/app/models/quiz.rb
+++ b/app/models/quiz.rb
@@ -115,7 +115,7 @@ def preselected_hide_solution(vertex_id, crosses)
def questions
ids = quiz_graph&.vertices&.values&.select { |v| v[:type] == "Question" }
- &.map { |v| v[:id] }
+ &.pluck(:id)
Question.where(id: ids)
end
diff --git a/app/models/quiz_certificate.rb b/app/models/quiz_certificate.rb
index e73fd7b6f..80c9fa8ed 100644
--- a/app/models/quiz_certificate.rb
+++ b/app/models/quiz_certificate.rb
@@ -1,5 +1,5 @@
class QuizCertificate < ApplicationRecord
- belongs_to :quiz, class_name: "Medium", inverse_of: :quiz_certificate
+ belongs_to :quiz, class_name: "Medium", inverse_of: :quiz_certificates
belongs_to :user, optional: true
before_create :set_code
diff --git a/app/models/referral.rb b/app/models/referral.rb
index 769d0a116..59f70ad6f 100644
--- a/app/models/referral.rb
+++ b/app/models/referral.rb
@@ -33,7 +33,7 @@ def vtt_time_span
# provide metadata for vtt file
def vtt_properties
- link = (item.link.presence || item.medium_link)
+ link = item.link.presence || item.medium_link
# at the moment, relations between items can be only of the form
# script <-> video, which means that between them there will be at most
# one script, one manuscript and one video
diff --git a/app/models/submission.rb b/app/models/submission.rb
index 61c640286..ecdfa44b5 100644
--- a/app/models/submission.rb
+++ b/app/models/submission.rb
@@ -63,7 +63,7 @@ def correction_size
end
def preceding_tutorial(user)
- assignment.previous&.map { |a| a.tutorial(user) }&.compact&.first
+ assignment.previous&.filter_map { |a| a.tutorial(user) }&.first
end
def invited_users
diff --git a/app/models/tag.rb b/app/models/tag.rb
index c92002ec0..d506e1a4f 100644
--- a/app/models/tag.rb
+++ b/app/models/tag.rb
@@ -184,13 +184,11 @@ def self.select_by_title_except(excluded_tags)
# converts the subgraph of all tags of distance <= 2 to the given marked tag
# into a cytoscape array representing this subgraph
def self.to_cytoscape(tags, marked_tag, highlight_related_tags: true)
- result = []
# add vertices
- tags.each do |t|
- result.push(data: t.cytoscape_vertex(marked_tag,
- highlight_related_tags:
- highlight_related_tags))
+ result = tags.map do |t|
+ { data: t.cytoscape_vertex(marked_tag, highlight_related_tags: highlight_related_tags) }
end
+
# add edges
edges = []
tags.each do |t|
diff --git a/app/models/term.rb b/app/models/term.rb
index 28999bcea..1fe8d72bb 100644
--- a/app/models/term.rb
+++ b/app/models/term.rb
@@ -4,7 +4,7 @@ class Term < ApplicationRecord
has_many :lectures
# season can only be SS/WS, and there can be only one of this type each year
- validates :season, presence: true, # rubocop:todo Rails/UniqueValidationWithoutIndex
+ validates :season, presence: true,
inclusion: { in: ["SS", "WS"] },
uniqueness: { scope: :year }
# a year >=2000 needs to be present
diff --git a/app/models/user.rb b/app/models/user.rb
index 7745f2260..4b612f20e 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -78,6 +78,9 @@ class User < ApplicationRecord
# a user has a watchlist with watchlist_entries
has_many :watchlists, dependent: :destroy
+
+ has_many :feedbacks, dependent: :destroy
+
include ScreenshotUploader[:image]
# if a homepage is given it should at leat be a valid address
@@ -549,14 +552,38 @@ def subscribed_commentable_media_with_comments
.select { |m| m.commontator_thread.comments.any? }
end
- def media_latest_comments
- media = subscribed_commentable_media_with_comments
- .map do |m|
- { medium: m,
- thread: m.commontator_thread,
- latest_comment: m.commontator_thread
- .comments.max_by(&:created_at) }
+ # Returns the media that the user has subscribed to and that have been
+ # commented on by somebody else (not by the current user). The order is
+ # given by the time of the latest comment by somebody else.
+ #
+ # Media that have not been commented on by somebody else than the current user,
+ # are not returned (!).
+ #
+ # For each medium, the following information is stored:
+ # - the medium itself
+ # - the thread of the medium
+ # - the latest comment by somebody else than the current user
+ # - the latest comment by any user (which might include the current user)
+ def subscribed_media_with_latest_comments_not_by_creator
+ media = []
+
+ subscribed_commentable_media_with_comments.each do |m|
+ thread = m.commontator_thread
+ comments = thread.comments
+ next if comments.blank?
+
+ comments_not_by_creator = comments.reject { |c| c.creator == self }
+ next if comments_not_by_creator.blank?
+
+ latest_comment = comments_not_by_creator.max_by(&:created_at)
+ latest_comment_by_any_user = comments.max_by(&:created_at)
+
+ media << { medium: m,
+ thread: thread,
+ latest_comment: latest_comment,
+ latest_comment_by_any_user: latest_comment_by_any_user }
end
+
media.sort_by { |x| x[:latest_comment].created_at }.reverse
end
diff --git a/app/models/user_cleaner.rb b/app/models/user_cleaner.rb
index 877760420..8a5763e53 100644
--- a/app/models/user_cleaner.rb
+++ b/app/models/user_cleaner.rb
@@ -3,9 +3,9 @@ class UserCleaner
attr_accessor :imap, :email_dict, :hash_dict
def login
- @imap = Net::IMAP.new(ENV.fetch("IMAPSERVER", nil), port: 993, ssl: true)
- @imap.authenticate("LOGIN", ENV.fetch("PROJECT_EMAIL_USERNAME", nil),
- ENV.fetch("PROJECT_EMAIL_PASSWORD", nil))
+ @imap = Net::IMAP.new(ENV.fetch("IMAPSERVER"), port: 993, ssl: true)
+ @imap.authenticate("LOGIN", ENV.fetch("PROJECT_EMAIL_USERNAME"),
+ ENV.fetch("PROJECT_EMAIL_PASSWORD"))
end
def logout
@@ -15,7 +15,7 @@ def logout
def search_emails_and_hashes
@email_dict = {}
@hash_dict = {}
- @imap.examine(ENV.fetch("PROJECT_EMAIL_MAILBOX", nil))
+ @imap.examine(ENV.fetch("PROJECT_EMAIL_MAILBOX"))
# Mails containing multiple email addresses (Subject: "Undelivered Mail Returned to Sender")
@imap.search(["SUBJECT",
"Undelivered Mail Returned to Sender"]).each do |message_id|
@@ -89,11 +89,11 @@ def send_hashes
end
def delete_ghosts
- @hash_dict.each do |mail, hash|
- u = User.find_by(email: mail, ghost_hash: hash)
- move_mail(@email_dict[mail]) if u.present? && @email_dict.present?
- u.destroy! if u&.generic?
- end
+ # @hash_dict.each do |mail, hash|
+ # u = User.find_by(email: mail, ghost_hash: hash)
+ # move_mail(@email_dict[mail]) if u.present? && @email_dict.present?
+ # u.destroy! if u&.generic?
+ # end
end
def move_mail(message_ids, attempt = 0)
@@ -103,7 +103,7 @@ def move_mail(message_ids, attempt = 0)
return if attempt > 3
begin
- @imap.examine(ENV.fetch("PROJECT_EMAIL_MAILBOX", nil))
+ @imap.examine(ENV.fetch("PROJECT_EMAIL_MAILBOX"))
@imap.move(message_ids, "Other Users/mampf/handled_bounces")
rescue Net::IMAP::BadResponseError
move_mail(message_ids, attempt + 1)
@@ -111,14 +111,15 @@ def move_mail(message_ids, attempt = 0)
end
def clean!
- login
- search_emails_and_hashes
- return if @email_dict.blank?
-
- send_hashes
- sleep(10)
- search_emails_and_hashes
- delete_ghosts
- logout
+ # TODO: Implement new user cleaner logic
+ # login
+ # search_emails_and_hashes
+ # return if @email_dict.blank?
+
+ # send_hashes
+ # sleep(10)
+ # search_emails_and_hashes
+ # delete_ghosts
+ # logout
end
end
diff --git a/app/views/administration/_navbar.html.erb b/app/views/administration/_navbar.html.erb
index cf4959437..f6d01c6d6 100644
--- a/app/views/administration/_navbar.html.erb
+++ b/app/views/administration/_navbar.html.erb
@@ -37,7 +37,7 @@
<%= link_to '', administration_search_path,
- class: 'nav-link bi bi-binoculars-fill ' + get_class_for_path(administration_search_path()),
+ class: 'nav-link bi bi-search ' + get_class_for_path(administration_search_path()),
data: { 'bs-toggle': 'tooltip' },
title: t('navbar.search') %>
diff --git a/app/views/administration/index.html.erb b/app/views/administration/index.html.erb
index 57306b6f3..ec2af45c1 100644
--- a/app/views/administration/index.html.erb
+++ b/app/views/administration/index.html.erb
@@ -1,10 +1,10 @@
-
+
<%= render partial: 'administration/index/my_lectures' %>
-
+
<%= render partial: 'administration/index/my_courses' %>
diff --git a/app/views/annotations/_annotation_area.html.erb b/app/views/annotations/_annotation_area.html.erb
new file mode 100644
index 000000000..ab0cfa8e8
--- /dev/null
+++ b/app/views/annotations/_annotation_area.html.erb
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/views/annotations/_annotation_locales.html.erb b/app/views/annotations/_annotation_locales.html.erb
new file mode 100644
index 000000000..e7661d764
--- /dev/null
+++ b/app/views/annotations/_annotation_locales.html.erb
@@ -0,0 +1,19 @@
+<% I18n.locale = current_user.locale %>
+
+
+
diff --git a/app/views/annotations/_annotation_modal.html.erb b/app/views/annotations/_annotation_modal.html.erb
new file mode 100644
index 000000000..7c5038921
--- /dev/null
+++ b/app/views/annotations/_annotation_modal.html.erb
@@ -0,0 +1,39 @@
+
diff --git a/app/views/annotations/_form.html.erb b/app/views/annotations/_form.html.erb
new file mode 100644
index 000000000..62cdf13f8
--- /dev/null
+++ b/app/views/annotations/_form.html.erb
@@ -0,0 +1,157 @@
+
+ <%= form_with model: @annotation do |f| %>
+
+ <%= t('admin.annotation.time') %>
+  
+ <%= TimeStamp.new(total_seconds: @total_seconds).hms_colon_string %>
+
+
+
+ <%= f.hidden_field :medium_id, value: @medium_id %>
+ <%= f.hidden_field :total_seconds, value: @total_seconds %>
+
+
+ <%= @annotation.id %>
+
+
+
+ <%= @annotation.subcategory %>
+
+
+
+
+
<%= t('admin.annotation.comment') %>
+ <%= f.text_area :comment, class: 'form-control' %>
+
+
+
+
+
+ <%= t('admin.remark.preview') %>
+
+
+
+
+
+ <% colors = Annotation.colors %>
+ <% for i in 1..15 do %>
+ <%= f.radio_button :color,
+ colors[i],
+ id: "annotation_color#{i}" %>
+ <%= f.label :color,
+ "annotation_color#{i}",
+ for: "annotation_color#{i}" do %>
+
+ <% end %>
+ <% end %>
+
+
+
+
<%= t('admin.annotation.category') %>
+ <%= helpdesk(t('admin.annotation.category_tooltip'), false) %>
+
+
+
+
+
+
+
+ <% annotations_status = Medium.find_by(id: @medium_id).get_annotations_status %>
+ <% if annotations_status == 1 %>
+
+ <%= f.check_box :visible_for_teacher, class: "form-check-input" %>
+ <%= f.label :visible_for_teacher,
+ t('admin.annotation.visible_for_teacher'),
+ class: "form-check-label" %>
+ <%= helpdesk(t('admin.annotation.visible_for_teacher_helpdesk'), false) %>
+
+ <% else %>
+ <%= f.hidden_field :visible_for_teacher, value: false %>
+ <% end %>
+
+
+
+ <%= f.check_box :post_as_comment, class: "form-check-input" %>
+ <%= f.label :post_as_comment,
+ t('admin.annotation.post_as_comment'),
+ class: "form-check-label" %>
+ <%= helpdesk(t('admin.annotation.post_as_comment_helpdesk'), false) %>
+
+
+
+
+
+ <% end %>
+
diff --git a/app/views/annotations/_form_content.html.erb b/app/views/annotations/_form_content.html.erb
new file mode 100644
index 000000000..3ea42b6f7
--- /dev/null
+++ b/app/views/annotations/_form_content.html.erb
@@ -0,0 +1,44 @@
+
<%= t('admin.annotation.whats_the_problem') %>
+
+
+
+
+
+
diff --git a/app/views/annotations/duplicate_comment.js.erb b/app/views/annotations/duplicate_comment.js.erb
new file mode 100644
index 000000000..51a33fe4b
--- /dev/null
+++ b/app/views/annotations/duplicate_comment.js.erb
@@ -0,0 +1,4 @@
+var $warningElement = $("#annotation-comment-warning");
+var message = document.getElementById("annotation-locales").dataset.warningDoublePosted;
+$warningElement.html(message);
+$warningElement.show();
diff --git a/app/views/annotations/edit.js.erb b/app/views/annotations/edit.js.erb
new file mode 100644
index 000000000..c7b199108
--- /dev/null
+++ b/app/views/annotations/edit.js.erb
@@ -0,0 +1,286 @@
+// When the modal opens, all key listeners must be
+// deactivated until the modal gets closed again
+thymeAttributes.lockKeyListeners = true;
+$("#annotation-modal").on("hidden.bs.modal", function () {
+ thymeAttributes.lockKeyListeners = false;
+});
+
+$("#annotation-modal-content").empty()
+ .append("<%= j render partial: "annotations/form"%>");
+$("#annotation-modal").modal("show");
+
+var submitButton = document.getElementById("annotation-modal-submit-button");
+var $postAsComment = $("#annotation_post_as_comment");
+var posted = <%= @posted %>;
+var isNewAnnotation = <%= @is_new_annotation %>;
+
+$postAsComment.on("change", function () {
+ // Don't show warnings if the annotation was already posted
+ if (posted) {
+ return;
+ }
+
+ if (this.checked) {
+ const $warningElement = $("#annotation-comment-warning");
+ const message = constructWarningMessage();
+ $warningElement.html(message);
+ $warningElement.show();
+ }
+ else {
+ $("#annotation-comment-warning").hide();
+ }
+});
+
+function constructWarningMessage() {
+ let message = $("#annotation-locales").data("warningPublishing");
+
+ const mistakeRatio = $("#annotation_category_mistake");
+ if (!mistakeRatio.is(":checked")) {
+ return message;
+ }
+
+ // Mistake specific warnings
+ $.ajax(Routes.num_nearby_posted_mistake_annotations_path(), {
+ type: "GET",
+ dataType: "json",
+ async: false,
+ data: {
+ mediumId: thyme.dataset.medium,
+ timestamp: video.currentTime,
+ },
+ success: function (count) {
+ const locale = $("#annotation-locales");
+ message += "
";
+ if (!count) {
+ message += $("#annotation-locales").data("warningMistake");
+ }
+ else if (count == 1) {
+ message += locale.data("warningOneCloseAnnotation");
+ }
+ else {
+ message += locale.data("warningMultipleCloseAnnotations1")
+ + count + locale.data("warningMultipleCloseAnnotations2");
+ }
+ return message;
+ },
+ error: function (err) {
+ console.error("Error while fetching nearby annotations");
+ console.error(err);
+ return message;
+ },
+ });
+
+ return message;
+}
+
+/*
+ * CATEGORY
+ */
+
+var categoryRadios = document.getElementById("category-radios");
+
+categoryRadios.addEventListener("click", function (evt) {
+ if (evt.target && event.target.matches("input[type='radio']")) {
+ switch (evt.target.value) {
+ case Category.NOTE.name:
+ note();
+ break;
+ case Category.CONTENT.name:
+ content();
+ break;
+ case Category.MISTAKE.name:
+ mistake();
+ break;
+ case Category.PRESENTATION.name:
+ presentation();
+ break;
+ }
+ }
+});
+
+function note() {
+ $("#annotation-category-specific").empty();
+ submitButton.disabled = false;
+ visibleForTeacher(false);
+ postComment(false);
+}
+
+function content() {
+ $("#annotation-category-specific").empty()
+ .append("<%= j render partial: "annotations/form_content"%>");
+ // disable submit button until the content category is selected
+ submitButton.disabled = true;
+ visibleForTeacher(true);
+ postComment(false);
+ var contentCategoryRadios = document.getElementById("content-category-radios");
+ contentCategoryRadios.addEventListener("click", function (evt) {
+ if (evt.target && event.target.matches("input[type='radio']")) {
+ submitButton.disabled = false;
+ }
+ });
+}
+
+function mistake() {
+ $("#annotation-category-specific").empty();
+ submitButton.disabled = false;
+ visibleForTeacher(true);
+ postComment(true);
+}
+
+function presentation() {
+ $("#annotation-category-specific").empty();
+ submitButton.disabled = false;
+ visibleForTeacher(true);
+ postComment(false);
+}
+
+function updatePreview() {
+ const text = $("#annotation_comment").val();
+ $("#annotation-modal-preview").empty();
+ $("#annotation-modal-preview").append(text.replaceAll("\n", "
"));
+ renderMathInElement(document.getElementById("annotation-modal-preview"), {
+ delimiters: [
+ {
+ left: "$$",
+ right: "$$",
+ display: true,
+ }, {
+ left: "$",
+ right: "$",
+ display: false,
+ }, {
+ left: "\\(",
+ right: "\\)",
+ display: false,
+ }, {
+ left: "\\[",
+ right: "\\]",
+ display: true,
+ },
+ ],
+ throwOnError: false,
+ });
+}
+
+/*
+ * Color
+ */
+function initModalBackgroundAnnotationColor() {
+ // Init event handler
+ $("#annotation-color-picker").on("click", "input[type='radio']", function (event) {
+ setModalColor(event.target.value);
+ });
+
+ // New annotation
+ if (isNewAnnotation) {
+ const randomNumber = Math.floor(Math.random() * 15) + 1;
+ const colorRadio = $(`#annotation_color${randomNumber}`);
+ colorRadio.click();
+ return;
+ }
+
+ // Edit annotation
+ const $selectedColor = $("#annotation-color-picker input[type=radio]:checked");
+ if ($selectedColor.length) {
+ $selectedColor.click();
+ }
+}
+
+function setModalColor(hexColorString) {
+ const color = thymeUtility.lightenUp(hexColorString, 2);
+ $(".modal-header").css("background-color", color);
+}
+
+initModalBackgroundAnnotationColor();
+
+/*
+ * Auxiliary methods
+ */
+function visibleForTeacher(isVisible) {
+ $("#annotation_visible_for_teacher").prop("checked", isVisible).trigger("change");
+}
+
+function postComment(isVisible) {
+ isVisible = posted ? true : isVisible;
+ $("#annotation_post_as_comment").prop("checked", isVisible).trigger("change");
+}
+
+function previewCSS(shouldShowPreview) {
+ if (shouldShowPreview) {
+ updatePreview();
+ $("#annotation-preview-section").show();
+
+ $("#annotation-modal-dialog").removeClass("annotation-dialog-normal");
+ $("#annotation-modal-dialog").addClass("annotation-dialog-expanded");
+
+ $("#annotation-content-section").addClass("annotation-content-spacing");
+ $("#annotation-content-section").removeClass("annotation-content-normal");
+ $("#annotation-content-section").addClass("annotation-content-expanded");
+ }
+ else {
+ $("#annotation-preview-section").hide();
+
+ $("#annotation-modal-dialog").removeClass("annotation-dialog-expanded");
+ $("#annotation-modal-dialog").addClass("annotation-dialog-normal");
+
+ $("#annotation-content-section").removeClass("annotation-content-spacing");
+ $("#annotation-content-section").removeClass("annotation-content-expanded");
+ $("#annotation-content-section").addClass("annotation-content-normal");
+ }
+}
+
+// Change modal title depending on the method that opens the modal
+var editSpan = $("#modal-title-edit-annotation");
+var createSpan = $("#modal-title-create-annotation");
+
+<% if action_name == 'new' %>
+createSpan.show();
+editSpan.hide();
+<% elsif action_name == 'edit' %>
+createSpan.hide();
+editSpan.show();
+<% end %>
+
+/* If this script is rendered by the edit method of the annotation controller:
+ Select correct subcategory (this is not automatically done by the rails form
+ as the content is dynamically rendered). */
+var contentRadio = $("#annotation_category_content");
+if (contentRadio.is(":checked")) {
+ content();
+ submitButton.disabled = false;
+ var subcategory = document.getElementById("annotation_subcategory").textContent.replace(/[^a-z]/g, "");
+ switch (subcategory) {
+ case Subcategory.DEFINITION.name:
+ document.getElementById("content-category-definition").checked = true;
+ break;
+ case Subcategory.ARGUMENT.name:
+ document.getElementById("content-category-argument").checked = true;
+ break;
+ case Subcategory.STRATEGY.name:
+ document.getElementById("content-category-strategy").checked = true;
+ break;
+ }
+}
+
+// render preview
+var annotationComment = document.getElementById("annotation_comment");
+annotationComment.addEventListener("input", function () {
+ updatePreview();
+});
+previewCSS(false); // Initialize modal without preview
+
+// preview toggle listener
+var previewToggle = document.getElementById("preview-toggle");
+previewToggle.addEventListener("change", function () {
+ const shouldShowPreview = $("#preview-toggle-check").is(":checked");
+ previewCSS(shouldShowPreview);
+});
+
+// disable post comment checkbox if annotation was already posted
+if (posted) {
+ postComment(true);
+ $postAsComment.get(0).disabled = true;
+}
+
+initBootstrapPopovers();
+previewCSS(true);
diff --git a/app/views/annotations/update.js.erb b/app/views/annotations/update.js.erb
new file mode 100644
index 000000000..10e43f354
--- /dev/null
+++ b/app/views/annotations/update.js.erb
@@ -0,0 +1,11 @@
+// Update annotations after submitting the annotations form
+thymeAttributes.annotationManager.updateAnnotations(() => {
+ // After creation/update of an annotation,
+ // show the annotation in the annotation area.
+ <% if @annotation %>
+ var newAnnotationId = <%= @annotation.id %>;
+ thymeAttributes.annotationArea.showAnnotationWithId(newAnnotationId);
+ <% end %>
+});
+
+$("#annotation-modal").modal("hide");
diff --git a/app/views/answers/cancel_edit.js.erb b/app/views/answers/cancel_edit.js.erb
new file mode 100644
index 000000000..7fafd4631
--- /dev/null
+++ b/app/views/answers/cancel_edit.js.erb
@@ -0,0 +1,39 @@
+var answerId = <%= @answer.id %>;
+var answerCard = $(`#answers-accordion > #answer-card-${answerId}`);
+window.registeredDiscardListeners.delete(answerId);
+
+// eslint-disable-next-line @stylistic/quotes
+var newAnswerCardElements = $(`<%= j render partial: 'answers/card', locals: { answer: @answer } %>`).children();
+
+// re-render possible MathJax content
+// eslint-disable-next-line no-undef
+renderMathInElement(newAnswerCardElements.get(0), {
+ delimiters: [
+ {
+ left: "$$",
+ right: "$$",
+ display: true,
+ },
+ {
+ left: "$",
+ right: "$",
+ display: false,
+ },
+ {
+ left: "\\(",
+ right: "\\)",
+ display: false,
+ },
+ {
+ left: "\\[",
+ right: "\\]",
+ display: true,
+ },
+ ],
+ throwOnError: false,
+},
+);
+
+setTimeout(() => {
+ answerCard.empty().append(newAnswerCardElements);
+}, 100);
diff --git a/app/views/answers/update_answer_box.coffee b/app/views/answers/update_answer_box.coffee
deleted file mode 100644
index 4d1a0971f..000000000
--- a/app/views/answers/update_answer_box.coffee
+++ /dev/null
@@ -1,3 +0,0 @@
-$('#answer-box-<%= @answer_id %>').empty().append '<%= ballot_box(@value) %>'
-$('#answer-header-<%= @answer_id %>').removeClass('bg-correct')
- .removeClass('bg-incorrect').addClass '<%= bgcolor(@value) %>'
diff --git a/app/views/commontator/comments/_body.html.erb b/app/views/commontator/comments/_body.html.erb
index 6ec78b6ee..f6e3df238 100644
--- a/app/views/commontator/comments/_body.html.erb
+++ b/app/views/commontator/comments/_body.html.erb
@@ -4,3 +4,14 @@
%>
<%= commontator_simple_format comment.body %>
+
+
+<% annotation = comment.annotation %>
+<% unless annotation.nil? %>
+ <% medium = annotation.medium %>
+ <% timestamp = annotation.timestamp %>
+
+ Timestamp:
+
+ <%= timestamp.hms_colon_string %>
+<% end %>
diff --git a/app/views/commontator/comments/_form.html.erb b/app/views/commontator/comments/_form.html.erb
index cd51730c2..58ba9c579 100644
--- a/app/views/commontator/comments/_form.html.erb
+++ b/app/views/commontator/comments/_form.html.erb
@@ -38,7 +38,7 @@
<%=
- form.text_area :body, rows: '7', class: 'form-control commentForm', id: new_record ?
+ form.text_area :body, rows: '7', class: 'form-control commentForm comment-field', id: new_record ?
comment.parent.nil? ? "commontator-thread-#{@commontator_thread.id}-new-comment-body" :
"commontator-comment-#{comment.parent.id}-reply" :
"commontator-comment-#{comment.id}-edit-body"
@@ -78,7 +78,7 @@
comment.parent.nil? ? "commontator-thread-#{@commontator_thread.id}-new-comment-body-preview" :
"commontator-comment-#{comment.parent.id}-reply-preview" :
"commontator-comment-#{comment.id}-edit-body-preview" %>'
- class="border p-2"
+ class="border p-2 comment-field"
style="display: none; min-height: 3em;">
<%= commontator_simple_format comment.body %>
diff --git a/app/views/commontator/comments/_list.html.erb b/app/views/commontator/comments/_list.html.erb
index 8a080e164..5ae58a0f9 100644
--- a/app/views/commontator/comments/_list.html.erb
+++ b/app/views/commontator/comments/_list.html.erb
@@ -1,3 +1,4 @@
+<%= stylesheet_link_tag 'comments' %>
<%#
Controllers that use this partial must supply the following variables:
user
@@ -5,7 +6,7 @@
%>
<% nested_comments.each do |comment, nested_children| %>
-
<% course.editors.each do |e| %>-
diff --git a/app/views/devise/registrations/new.html.erb b/app/views/devise/registrations/new.html.erb
index 2cb17d595..2e47ea0f0 100644
--- a/app/views/devise/registrations/new.html.erb
+++ b/app/views/devise/registrations/new.html.erb
@@ -33,13 +33,13 @@
target: :_blank)), class: "d-inline", for: "dsgvo-consent" %>