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 @@
  • 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 %> + + + + <%= t('admin.annotation.comment') %> + <%= f.text_area :comment, class: 'form-control' %> + + +
    + + +
    + + +
    + <% 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) %> +
    +
    + <%= f.radio_button :category, + :note, + class: 'form-check-input' %> + <%= f.label :category, + t('admin.annotation.note'), + value: :note, + class: 'form-check-label' %> +
    +
    + <%= f.radio_button :category, + :content, + class: 'form-check-input' %> + <%= f.label :category, + t('admin.annotation.content'), + value: :content, + class: 'form-check-label' %> +
    +
    + <%= f.radio_button :category, + :mistake, + class: 'form-check-input' %> + <%= f.label :category, + t('admin.annotation.mistake'), + value: :mistake, + class: 'form-check-label' %> +
    +
    + <%= f.radio_button :category, + :presentation, + class: 'form-check-input' %> + <%= f.label :category, + t('admin.annotation.presentation'), + value: :presentation, + class: 'form-check-label' %> +
    +
    + +
    + +
    + + + <% 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| %> -
    +
    <%= render partial: 'commontator/comments/show', formats: [ :html ], locals: { user: user, comment: comment, nested_children: nested_children diff --git a/app/views/commontator/threads/_show.html.erb b/app/views/commontator/threads/_show.html.erb index f8b50e218..1f238deb8 100644 --- a/app/views/commontator/threads/_show.html.erb +++ b/app/views/commontator/threads/_show.html.erb @@ -1,3 +1,4 @@ +<%= stylesheet_link_tag 'comments' %> <% # Views that use this partial must supply the following variables: # user @@ -69,7 +70,7 @@ <%= render partial: 'commontator/threads/reply', locals: { thread: thread, user: user } %> -
    +
    <%= render partial: 'commontator/comments/list', locals: { user: user, nested_comments: nested_comments diff --git a/app/views/commontator/threads/show.js.erb b/app/views/commontator/threads/show.js.erb index d0bc1b90f..43999e458 100644 --- a/app/views/commontator/threads/show.js.erb +++ b/app/views/commontator/threads/show.js.erb @@ -7,6 +7,6 @@ } %> -$("#commontator-thread-<%= @commontator_thread.id %>-comment-list").hide().fadeIn(); +$("#commontator-thread-<%= @commontator_thread.id %>-comment-field").hide().fadeIn(); <%= javascript_proc %> diff --git a/app/views/courses/_basics.html.erb b/app/views/courses/_basics.html.erb index f2bbc8bbc..298e7c283 100644 --- a/app/views/courses/_basics.html.erb +++ b/app/views/courses/_basics.html.erb @@ -70,6 +70,9 @@
    <% else %> <%= t('basics.editors') %> + <%= helpdesk(t('admin.course.info.no_right_to_change_editors', + project_email: mail_to(DefaultSetting::PROJECT_EMAIL)), + true) %>
      <% 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" %>
    <%= f.hidden_field :locale, value: I18n.locale %> - <% if ENV['USE_CAPTCHA_SERVICE']%> + <% if ENV["USE_CAPTCHA_SERVICE"]%>

    -

    +

    <%end %>
    - <%= f.submit t('.sign_up'), class: 'btn btn-primary', id: 'register-user', disabled:ENV['USE_CAPTCHA_SERVICE'] %> + <%= f.submit t('.sign_up'), class: 'btn btn-primary', id: 'register-user', disabled:ENV.fetch("USE_CAPTCHA_SERVICE") %>
    <% end %> diff --git a/app/views/exception_handler/mailers/new_exception.html.erb b/app/views/exception_handler/mailers/new_exception.html.erb index 93b3b9b89..0ebfbbe16 100644 --- a/app/views/exception_handler/mailers/new_exception.html.erb +++ b/app/views/exception_handler/mailers/new_exception.html.erb @@ -1,5 +1,5 @@ <%= t('exception.exception_report', - host: ENV['URL_HOST']) %> + host: ENV.fetch("URL_HOST")) %> <%= @exception.response %> (<%= @exception.status %>) diff --git a/app/views/feedback_mailer/new_user_feedback_email.text.erb b/app/views/feedback_mailer/new_user_feedback_email.text.erb new file mode 100644 index 000000000..9b580a4a4 --- /dev/null +++ b/app/views/feedback_mailer/new_user_feedback_email.text.erb @@ -0,0 +1,13 @@ +# Title (optional) +<%= @feedback.title %> + +# Feedback +<%= @feedback.feedback %> + + +----- +<% if @feedback.can_contact %> +Reply to this mail to contact the user. +<% else %> +User did not give permission to contact them regarding their feedback, so we cannot reply to this mail. +<% end %> diff --git a/app/views/feedbacks/_feedback.html.erb b/app/views/feedbacks/_feedback.html.erb new file mode 100644 index 000000000..b98f5c8bc --- /dev/null +++ b/app/views/feedbacks/_feedback.html.erb @@ -0,0 +1,44 @@ +<%= stylesheet_link_tag 'feedback' %> + +<%# Main Modal %> + + +<%# Messages toasts %> +
    + <%# Error %> + + + <%# Success %> + + +
    diff --git a/app/views/feedbacks/_feedback_button.html.erb b/app/views/feedbacks/_feedback_button.html.erb new file mode 100644 index 000000000..c3d9c02fa --- /dev/null +++ b/app/views/feedbacks/_feedback_button.html.erb @@ -0,0 +1,8 @@ +<%= stylesheet_link_tag 'feedback' %> + +<%# Feedback button %> + diff --git a/app/views/feedbacks/_feedback_form.html.erb b/app/views/feedbacks/_feedback_form.html.erb new file mode 100644 index 000000000..2e0833e3e --- /dev/null +++ b/app/views/feedbacks/_feedback_form.html.erb @@ -0,0 +1,63 @@ +<%= javascript_include_tag :feedback %> + + + + + + diff --git a/app/views/feedbacks/create.js.erb b/app/views/feedbacks/create.js.erb new file mode 100644 index 000000000..1538b24bf --- /dev/null +++ b/app/views/feedbacks/create.js.erb @@ -0,0 +1,7 @@ +<% if @feedback_success %> +$("#submit-feedback").modal("hide"); +$("#submit-feedback").find("form").trigger("reset"); // clear form +$("#toast-successfully-sent").toast("show"); +<% else %> +$("#toast-could-not-send").toast("show"); +<% end %> diff --git a/app/views/layouts/_head.html.erb b/app/views/layouts/_head.html.erb index a40692503..c67acb869 100644 --- a/app/views/layouts/_head.html.erb +++ b/app/views/layouts/_head.html.erb @@ -17,10 +17,6 @@ <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %> -