spectator_sport 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +137 -11
  3. data/app/controllers/spectator_sport/dashboard/application_controller.rb +8 -0
  4. data/app/controllers/spectator_sport/dashboard/dashboards_controller.rb +10 -0
  5. data/app/controllers/spectator_sport/dashboard/frontends_controller.rb +59 -0
  6. data/app/controllers/spectator_sport/dashboard/session_windows_controller.rb +34 -0
  7. data/app/controllers/spectator_sport/dashboard/tags_controller.rb +15 -0
  8. data/app/controllers/spectator_sport/events_controller.rb +45 -0
  9. data/app/frontend/spectator_sport/dashboard/application.js +31 -0
  10. data/app/frontend/spectator_sport/dashboard/icons.svg +79 -0
  11. data/app/frontend/spectator_sport/dashboard/modules/clipboard_controller.js +15 -0
  12. data/app/frontend/spectator_sport/dashboard/modules/player_controller.js +60 -0
  13. data/app/frontend/spectator_sport/dashboard/modules/theme_controller.js +44 -0
  14. data/app/frontend/spectator_sport/dashboard/style.css +87 -0
  15. data/app/frontend/spectator_sport/dashboard/vendor/bootstrap/bootstrap.bundle.min.js +6 -0
  16. data/app/frontend/spectator_sport/dashboard/vendor/bootstrap/bootstrap.min.css +6 -0
  17. data/app/frontend/spectator_sport/dashboard/vendor/es_module_shims.js +1 -0
  18. data/app/frontend/spectator_sport/dashboard/vendor/rrweb-player/rrweb-player.min.css +2 -0
  19. data/app/frontend/spectator_sport/dashboard/vendor/rrweb-player/rrweb-player.min.js +25 -0
  20. data/app/frontend/spectator_sport/dashboard/vendor/stimulus.js +2408 -0
  21. data/app/frontend/spectator_sport/dashboard/vendor/turbo.js +37 -0
  22. data/app/helpers/spectator_sport/dashboard/application_helper.rb +7 -0
  23. data/app/helpers/spectator_sport/dashboard/icons_helper.rb +15 -0
  24. data/app/helpers/spectator_sport/script_helper.rb +12 -0
  25. data/app/models/spectator_sport/event.rb +63 -0
  26. data/app/models/spectator_sport/session.rb +6 -0
  27. data/app/models/spectator_sport/session_window.rb +11 -0
  28. data/app/models/spectator_sport/session_window_analysis.rb +11 -0
  29. data/app/models/spectator_sport/session_window_tag.rb +13 -0
  30. data/app/views/layouts/spectator_sport/dashboard/application.html.erb +26 -0
  31. data/app/views/spectator_sport/dashboard/dashboards/index.html.erb +54 -0
  32. data/app/views/spectator_sport/dashboard/session_windows/_more_events_frame.erb +1 -0
  33. data/app/views/spectator_sport/dashboard/session_windows/details.html.erb +22 -0
  34. data/app/views/spectator_sport/dashboard/session_windows/events.erb +13 -0
  35. data/app/views/spectator_sport/dashboard/session_windows/show.html.erb +35 -0
  36. data/app/views/spectator_sport/dashboard/tags/show.html.erb +30 -0
  37. data/app/views/spectator_sport/events/index.js +248 -0
  38. data/app/views/spectator_sport/shared/_navbar.erb +63 -0
  39. data/config/dashboard_routes.rb +15 -0
  40. data/config/git_worktree.rb +41 -0
  41. data/config/routes.rb +1 -0
  42. data/lib/spectator_sport/engine.rb +14 -0
  43. data/lib/spectator_sport/version.rb +1 -1
  44. metadata +41 -9
  45. data/app/assets/config/spectator_sport_manifest.js +0 -1
  46. data/app/assets/stylesheets/spectator_sport/application.css +0 -15
  47. data/app/views/layouts/spectator_sport/application.html.erb +0 -17
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a0da6de1b75e1f786faff4bf46463607dbe7bb14f2ceb7bfc78019941e303406
4
- data.tar.gz: 45ad646c162867c8ef55212beac6300f332f6661a05d6d0146bda83eefc98216
3
+ metadata.gz: 04ef1fe5b218478993467e2b24f8279ce6385978ae57800ecc27dd8a98afa9af
4
+ data.tar.gz: b3730a2d748d434c36264ae7018a3c40a217c16dca9ba12c2956143f01a535f7
5
5
  SHA512:
6
- metadata.gz: ba00d3a19fe595a27617e7fa43aef0b826ebbda32bfae2505fd00dc036045f56ce3d107c8deb634c6ae6f40c72ca20cfa915e447ca60f7135c72f0a55470cc51
7
- data.tar.gz: '030760899cef3bdb7fe743d09266147152a00090e436360a4b9cdf943a5b2b401f9334c42226425d995d6fd9c40df25ed06ba5517ba3b273e60e2fe31df41763'
6
+ metadata.gz: bdd63ac25ccfcf26e8e237ae925793ce186a3b1c13882fdb07f598d53f677661dca139e4f4085529b4214ea34547d812e0a80b531415c43c76d48752aad02cfb
7
+ data.tar.gz: b02788e838fc6d7d5190bfa0e47f70a02d78c919bf80f3166d7a332187f8e487a039380d3ebce8a07dd0fcccf5ff6eb0d31f0d6d7b6b4ffae9d78be4bb7fed88
data/README.md CHANGED
@@ -4,29 +4,155 @@ Record and replay browser sessions in a self-hosted Rails Engine.
4
4
 
5
5
  Spectator Sport uses the [`rrweb` library](https://www.rrweb.io/) to create recordings of your website's DOM as your users interact with it. These recordings are stored in your database for replay by developers and administrators to analyze user behavior, reproduce bugs, and make building for the web more fun.
6
6
 
7
- ## Usage
7
+ 🚧 🚧 _This gem is very early in its development lifecycle and will undergo significant changes on its journey to v1.0. I would love your feedback and help in co-developing it, just fyi that it's going to be so much better than it is right now._
8
8
 
9
- TBD.
9
+ 🚧 🚧 **Future Roadmap:**
10
+
11
+ - ✅ Proof of concept and technical demo
12
+ - ✅ Running in production on Ben Sheldon's personal business websites
13
+ - ✅ Publish [manifesto of principles and intent](https://github.com/bensheldon/spectator_sport/discussions/6)
14
+ - ◻️ Reliable and efficient event stream transport
15
+ - ✅ Player dashboard design using Bootstrap and Turbo ([#20](https://github.com/bensheldon/spectator_sport/pull/20))
16
+ - ◻️ Automatic cleanup of old recordings to minimize database space
17
+ - ◻️ Identity methods for linking application users to recordings
18
+ - ◻️ Privacy controls with masked recording by default
19
+ - ◻️ Automated installation process with Rails generators
20
+ - ◻️ Fully documented installation process
21
+ - 🏁 Release v1.0 🎉
22
+ - ◻️ Live streaming replay of recordings
23
+ - ◻️ Searching / filtering of recordings, including navigation and 404s/500s, button clicks, rage clicks, dead clicks, etc.
24
+ - ◻️ Custom events
25
+ - 💖 Your feedback and ideas. Please open an Issue or Discussion or even a PR modifying this Roadmap. I'd love to chat!
10
26
 
11
27
  ## Installation
12
- Add this line to your application's Gemfile:
28
+
29
+ The Spectator Sport gem is conceptually two parts packaged together in this single gem and mounted in your application:
30
+
31
+ 1. The Recorder, including javascript that runs in the client browser and produces a stream of events, an API endpoint to receive those events, and database migrations and models to store the events as a cohesive recording.
32
+ 2. The Player Dashboard, an administrative dashboard to view and replay stored recordings
33
+
34
+ To install Spectator Sport in your Rails application:
35
+
36
+ 1. Add `spectator_sport` to your application's Gemfile and install the gem:
37
+ ```bash
38
+ bundle add spectator_sport
39
+ ```
40
+ 2. Install Spectator Sport in your application. _🚧 This will change on the path to v1._ Explore the `/demo` app as live example:
41
+ - Create database migrations with `bin/rails g spectator_sport:install:migrations`. Apply migrations with `bin/rails db:prepare`
42
+ - Mount the recorder API in your application's routes with `mount SpectatorSport::Engine, at: "/spectator_sport, as: :spectator_sport"`
43
+ - Add the `spectator_sport_script_tags` helper to the bottom of the `<head>` of `layout/application.rb`. Example:
44
+ ```erb
45
+ <%# app/views/layouts/application.html.erb %>
46
+ <%# ... %>
47
+ <%= spectator_sport_script_tags %>
48
+ </head>
49
+ ```
50
+
51
+ - Add a `<script>` tag to `public/404.html`, `public/422.html`, and `public/500/html` error pages. Example:
52
+ ```erb
53
+ <!-- public/404.html -->
54
+ <!-- ... -->
55
+ <script defer src="/spectator_sport/events.js"></script>
56
+ </head>
57
+ ```
58
+ 3. To view recordings, you will want to mount the Player Dashboard in your application and set up authorization to limit access. See the section on [Dashboard authorization](#dashboard-authorization) for instructions.
59
+
60
+ ## Tagging recordings
61
+
62
+ You can associate a string tag (e.g. a user ID or account identifier) with the current recording by calling `spectator_sport_tag_recording` in any template:
63
+
64
+ ```erb
65
+ <%= spectator_sport_tag_recording(current_user.id.to_s) %>
66
+ ```
67
+
68
+ This renders a hidden `<meta>` element signed by the server. The recording client detects it and immediately sends it to the API, where the signature is verified before the tag is stored. Tags are displayed in the dashboard and can be used to look up all recordings associated with a given value.
69
+
70
+ **Note:** this requires the `spectator_sport_session_window_tags` migration to be applied (`bin/rails spectator_sport:install:migrations && bin/rails db:migrate`). If the migration hasn't been run, the feature is silently disabled.
71
+
72
+ ## Dashboard authorization
73
+
74
+ It is advisable to manually install and set up authorization for the **Player Dashboard** and refrain from making it public.
75
+
76
+ If you are using Devise, the process of authorizing admins might resemble the following:
13
77
 
14
78
  ```ruby
15
- gem "spectator_sport"
79
+ # config/routes.rb
80
+ authenticate :user, ->(user) { user.admin? } do
81
+ mount SpectatorSport::Dashboard::Engine, at: 'spectator_sport_dashboard', as: :spectator_sport_dashboard
82
+ end
16
83
  ```
17
84
 
18
- And then execute:
19
- ```bash
20
- $ bundle
85
+ Or set up Basic Auth:
86
+
87
+ ```ruby
88
+ # config/initializers/spectator_sport.rb
89
+ SpectatorSport::Dashboard::Engine.middleware.use(Rack::Auth::Basic) do |username, password|
90
+ ActiveSupport::SecurityUtils.secure_compare(Rails.application.credentials.spectator_sport_username, username) &
91
+ ActiveSupport::SecurityUtils.secure_compare(Rails.application.credentials.spectator_sport_password, password)
92
+ end
21
93
  ```
22
94
 
23
- Or install it yourself as:
24
- ```bash
25
- $ gem install spectator_sport
95
+ If you are using an authentication method similar to the one used in ONCE products, you can utilize an auth constraint in your routes.
96
+ ```ruby
97
+ # config/routes.rb
98
+ class AuthRouteConstraint
99
+ def matches?(request)
100
+ return false unless request.session[:user_id]
101
+ user = User.find(request.session[:user_id])
102
+
103
+ if user && user.admin?
104
+ cookies = ActionDispatch::Cookies::CookieJar.build(request, request.cookies)
105
+ token = cookies.signed[:session_token]
106
+
107
+ return user.sessions.find_by(token: token)
108
+ end
109
+ end
110
+ end
111
+
112
+ Rails.application.routes.draw do
113
+ # ...
114
+ namespace :admin, constraints: AuthRouteConstraint.new do
115
+ mount SpectatorSport::Dashboard::Engine, at: 'spectator_sport_dashboard', as: :spectator_sport_dashboard
116
+ end
117
+ end
118
+ ```
119
+
120
+ Or extend the `SpectatorSport::Dashboard::ApplicationController` with your own authorization logic:
121
+
122
+ ```ruby
123
+ # config/initializers/spectator_sport.rb
124
+ ActiveSupport.on_load(:spectator_sport_dashboard_application_controller) do
125
+ # context here is SpectatorSport::Dashboard::ApplicationController
126
+
127
+ before_action do
128
+ raise ActionController::RoutingError.new('Not Found') unless current_user&.admin?
129
+ end
130
+
131
+ def current_user
132
+ # load current user from session, cookies, etc.
133
+ end
134
+ end
26
135
  ```
27
136
 
28
137
  ## Contributing
29
- Contribution directions go here.
138
+
139
+ 💖 Please don't be shy about opening an issue or half-baked PR. Your ideas and suggestions are more important to discuss than a polished/complete code change.
140
+
141
+ This repository is intended to be simple and easy to run locally with a fully-featured demo application for immediately seeing the results of your proposed changes:
142
+
143
+ ```bash
144
+ # 1. Clone this repository via git
145
+ # 2. Set it up locally
146
+ bundle install
147
+ # 3. Create database
148
+ bin/rails db:setup
149
+ # 4. Run the demo Rails application:
150
+ bin/rails s
151
+ # 5. Load the demo application in your browser
152
+ open http://localhost:3000
153
+ # 6. Make changes, see the result, commit and make a PR!
154
+ ```
30
155
 
31
156
  ## License
157
+
32
158
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,8 @@
1
+ module SpectatorSport
2
+ module Dashboard
3
+ class ApplicationController < ActionController::Base
4
+ end
5
+ end
6
+ end
7
+
8
+ ActiveSupport.run_load_hooks(:spectator_sport_dashboard_application_controller, SpectatorSport::Dashboard::ApplicationController)
@@ -0,0 +1,10 @@
1
+ module SpectatorSport
2
+ module Dashboard
3
+ class DashboardsController < ApplicationController
4
+ def index
5
+ @session_windows = SessionWindow.order(:created_at).limit(50).reverse_order
6
+ @session_windows = @session_windows.includes(:session_window_tags) if SpectatorSport::SessionWindowTag.migrated?
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpectatorSport
4
+ module Dashboard
5
+ class FrontendsController < ActionController::Base # rubocop:disable Rails/ApplicationController
6
+ protect_from_forgery with: :exception
7
+ skip_after_action :verify_same_origin_request, raise: false
8
+
9
+ def self.asset_path(path)
10
+ Engine.root.join("app/frontend/spectator_sport/dashboard", path)
11
+ end
12
+
13
+ STATIC_ASSETS = {
14
+ css: {
15
+ bootstrap: asset_path("vendor/bootstrap/bootstrap.min.css"),
16
+ "rrweb-player": asset_path("vendor/rrweb-player/rrweb-player.min.css"),
17
+ style: asset_path("style.css")
18
+ },
19
+ js: {
20
+ bootstrap: asset_path("vendor/bootstrap/bootstrap.bundle.min.js"),
21
+ es_module_shims: asset_path("vendor/es_module_shims.js")
22
+ },
23
+ svg: {
24
+ icons: asset_path("icons.svg")
25
+ }
26
+ }.freeze
27
+
28
+ MODULE_OVERRIDES = {
29
+ application: asset_path("application.js"),
30
+ "rrweb-player": asset_path("vendor/rrweb-player/rrweb-player.min.js"),
31
+ stimulus: asset_path("vendor/stimulus.js"),
32
+ turbo: asset_path("vendor/turbo.js")
33
+ }.freeze
34
+
35
+ def self.js_modules
36
+ @_js_modules ||= asset_path("modules").children.select(&:file?).each_with_object({}) do |file, modules|
37
+ key = File.basename(file.basename.to_s, ".js").to_sym
38
+ modules[key] = file
39
+ end.merge(MODULE_OVERRIDES)
40
+ end
41
+
42
+ before_action do
43
+ expires_in 1.year, public: true
44
+ end
45
+
46
+ def static
47
+ file_path = STATIC_ASSETS.dig(params[:format]&.to_sym, params[:id]&.to_sym) || raise(ActionController::RoutingError, "Not Found")
48
+ send_file file_path, disposition: "inline"
49
+ end
50
+
51
+ def module
52
+ raise(ActionController::RoutingError, "Not Found") if params[:format] != "js"
53
+
54
+ file_path = self.class.js_modules[params[:id]&.to_sym] || raise(ActionController::RoutingError, "Not Found")
55
+ send_file file_path, disposition: "inline"
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,34 @@
1
+ module SpectatorSport
2
+ module Dashboard
3
+ class SessionWindowsController < ApplicationController
4
+ def show
5
+ @session_window = SessionWindow.find(params[:id])
6
+ @events = @session_window.events.page_after(nil)
7
+ @tags = SpectatorSport::SessionWindowTag.migrated? ? @session_window.session_window_tags.to_a : []
8
+ end
9
+
10
+ def events
11
+ response.content_type = "text/vnd.turbo-stream.html"
12
+ return head(:ok) if params[:after_event_id].blank?
13
+
14
+ session_window = SessionWindow.find(params[:id])
15
+ previous_event = session_window.events.find_by(id: params[:after_event_id])
16
+ @events = session_window.events.page_after(previous_event)
17
+
18
+ render layout: false
19
+ end
20
+
21
+ def details
22
+ @session_window = SessionWindow.find(params[:id])
23
+ end
24
+
25
+ def destroy
26
+ @session_window = SessionWindow.find(params[:id])
27
+ @session_window.events.delete_all
28
+ @session_window.delete
29
+
30
+ redirect_to root_path
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,15 @@
1
+ module SpectatorSport
2
+ module Dashboard
3
+ class TagsController < ApplicationController
4
+ def show
5
+ @tag = params[:name]
6
+ @session_windows = SpectatorSport::SessionWindowTag
7
+ .where(tag: @tag)
8
+ .includes(:session_window)
9
+ .order(created_at: :desc)
10
+ .limit(50)
11
+ .map(&:session_window)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,45 @@
1
+ module SpectatorSport
2
+ class EventsController < ApplicationController
3
+ skip_before_action :verify_authenticity_token
4
+
5
+ def index
6
+ end
7
+
8
+ def create
9
+ data = if params.key?(:sessionId) && params.key?(:windowId) && params.key?(:events)
10
+ params.slice(:sessionId, :windowId, :events, :tags).stringify_keys
11
+ else
12
+ # beacon sends JSON in the request body
13
+ JSON.parse(request.body.read).slice("sessionId", "windowId", "events", "tags")
14
+ end
15
+
16
+ session_secure_id = data["sessionId"]
17
+ window_secure_id = data["windowId"]
18
+ events = data["events"]
19
+
20
+ session = Session.find_or_create_by(secure_id: session_secure_id)
21
+ window = SessionWindow.find_or_create_by(secure_id: window_secure_id, session: session)
22
+
23
+ records_data = events.map do |event|
24
+ { session_id: session.id, session_window_id: window.id, event_data: event, created_at: Time.at(event["timestamp"].to_f / 1000.0) }
25
+ end.to_a
26
+ Event.insert_all(records_data) if records_data.any?
27
+
28
+ last_event = records_data.max_by { |data| data[:created_at] }
29
+ window.update(updated_at: last_event[:created_at]) if last_event
30
+
31
+ if SpectatorSport::SessionWindowTag.migrated?
32
+ verifier = Rails.application.message_verifier(:spectator_sport_tag_recording)
33
+ Array(data["tags"]).first(20).each do |signed_tag|
34
+ tag_value = verifier.verified(signed_tag)
35
+ next unless tag_value.is_a?(String) && tag_value.present?
36
+ SessionWindowTag.find_or_create_by(session_window: window, tag: tag_value)
37
+ rescue ActiveRecord::RecordNotUnique
38
+ nil
39
+ end
40
+ end
41
+
42
+ render json: { message: "ok" }
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,31 @@
1
+ /*jshint esversion: 6, strict: false */
2
+
3
+ import "turbo";
4
+ import { Application } from "stimulus";
5
+
6
+ document.addEventListener("turbo:load", function() {
7
+ document.documentElement.removeAttribute("data-turbo-not-loaded");
8
+ document.documentElement.removeAttribute("data-turbo-loading");
9
+ });
10
+
11
+ document.addEventListener("turbo:submit-start", function(event) {
12
+ if (!event.target.closest("turbo-frame")) {
13
+ document.documentElement.setAttribute("data-turbo-loading", "1");
14
+ }
15
+ });
16
+
17
+ document.addEventListener("turbo:submit-end", function(event) {
18
+ if (!event.detail.fetchResponse?.redirected) {
19
+ document.documentElement.removeAttribute("data-turbo-loading");
20
+ }
21
+ });
22
+
23
+ window.Stimulus = Application.start();
24
+
25
+
26
+ import ThemeController from "theme_controller";
27
+ import PlayerController from "player_controller";
28
+ import ClipboardController from "clipboard_controller";
29
+ Stimulus.register("theme", ThemeController);
30
+ Stimulus.register("player", PlayerController);
31
+ Stimulus.register("clipboard", ClipboardController);
@@ -0,0 +1,79 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg xmlns="http://www.w3.org/2000/svg">
3
+ <!-- https://icons.getbootstrap.com/icons/arrow-clockwise/ -->
4
+ <symbol id="arrow_clockwise" viewBox="0 0 16 16">
5
+ <path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z" />
6
+ <path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z" />
7
+ </symbol>
8
+ <!-- https://icons.getbootstrap.com/icons/check-circle/ -->
9
+ <symbol id="check" viewBox="0 0 16 16">
10
+ <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
11
+ <path d="M10.97 4.97a.235.235 0 0 0-.02.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05z" />
12
+ </symbol>
13
+ <!-- https://icons.getbootstrap.com/icons/circle-half/ -->
14
+ <symbol id="circle_half" viewBox="0 0 16 16">
15
+ <path d="M8 15A7 7 0 1 0 8 1v14zm0 1A8 8 0 1 1 8 0a8 8 0 0 1 0 16z" />
16
+ </symbol>
17
+ <!-- https://icons.getbootstrap.com/icons/clock/ -->
18
+ <symbol id="clock" viewBox="0 0 16 16">
19
+ <path d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71V3.5z" />
20
+ <path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z" />
21
+ </symbol>
22
+ <!-- https://icons.getbootstrap.com/icons/dash-circle/ -->
23
+ <symbol id="dash_circle" viewBox="0 0 16 16">
24
+ <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
25
+ <path d="M4 8a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7A.5.5 0 0 1 4 8z" />
26
+ </symbol>
27
+ <!-- https://icons.getbootstrap.com/icons/three-dots/ -->
28
+ <symbol id="dots" viewBox="0 0 16 16">
29
+ <path d="M3 9.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z" />
30
+ </symbol>
31
+ <!-- https://icons.getbootstrap.com/icons/eject/ -->
32
+ <symbol id="eject" viewBox="0 0 16 16">
33
+ <path d="M7.27 1.047a1 1 0 0 1 1.46 0l6.345 6.77c.6.638.146 1.683-.73 1.683H1.656C.78 9.5.326 8.455.926 7.816L7.27 1.047zM14.346 8.5 8 1.731 1.654 8.5h12.692zM.5 11.5a1 1 0 0 1 1-1h13a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-1zm14 0h-13v1h13v-1z" />
34
+ </symbol>
35
+ <!-- https://icons.getbootstrap.com/icons/exclamation-circle/ -->
36
+ <symbol id="exclamation" viewBox="0 0 16 16">
37
+ <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
38
+ <path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z" />
39
+ </symbol>
40
+ <!-- https://icons.getbootstrap.com/icons/globe/ -->
41
+ <symbol id="globe" viewBox="0 0 16 16">
42
+ <path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8m7.5-6.923c-.67.204-1.335.82-1.887 1.855A8 8 0 0 0 5.145 4H7.5zM4.09 4a9.3 9.3 0 0 1 .64-1.539 7 7 0 0 1 .597-.933A7.03 7.03 0 0 0 2.255 4zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a7 7 0 0 0-.656 2.5zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5zM8.5 5v2.5h2.99a12.5 12.5 0 0 0-.337-2.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5zM5.145 12q.208.58.468 1.068c.552 1.035 1.218 1.65 1.887 1.855V12zm.182 2.472a7 7 0 0 1-.597-.933A9.3 9.3 0 0 1 4.09 12H2.255a7 7 0 0 0 3.072 2.472M3.82 11a13.7 13.7 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5zm6.853 3.472A7 7 0 0 0 13.745 12H11.91a9.3 9.3 0 0 1-.64 1.539 7 7 0 0 1-.597.933M8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855q.26-.487.468-1.068zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.7 13.7 0 0 1-.312 2.5m2.802-3.5a7 7 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7 7 0 0 0-3.072-2.472c.218.284.418.598.597.933M10.855 4a8 8 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4z" />
43
+ </symbol>
44
+ <!-- https://icons.getbootstrap.com/icons/info-circle/ -->
45
+ <symbol id="info" viewBox="0 0 16 16">
46
+ <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
47
+ <path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z" />
48
+ </symbol>
49
+ <!-- https://icons.getbootstrap.com/icons/moon-stars-fill/ -->
50
+ <symbol id="moon_stars_fill" viewBox="0 0 16 16">
51
+ <path d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278z" />
52
+ <path d="M10.794 3.148a.217.217 0 0 1 .412 0l.387 1.162c.173.518.579.924 1.097 1.097l1.162.387a.217.217 0 0 1 0 .412l-1.162.387a1.734 1.734 0 0 0-1.097 1.097l-.387 1.162a.217.217 0 0 1-.412 0l-.387-1.162A1.734 1.734 0 0 0 9.31 6.593l-1.162-.387a.217.217 0 0 1 0-.412l1.162-.387a1.734 1.734 0 0 0 1.097-1.097l.387-1.162zM13.863.099a.145.145 0 0 1 .274 0l.258.774c.115.346.386.617.732.732l.774.258a.145.145 0 0 1 0 .274l-.774.258a1.156 1.156 0 0 0-.732.732l-.258.774a.145.145 0 0 1-.274 0l-.258-.774a1.156 1.156 0 0 0-.732-.732l-.774-.258a.145.145 0 0 1 0-.274l.774-.258c.346-.115.617-.386.732-.732L13.863.1z" />
53
+ </symbol>
54
+ <!-- https://icons.getbootstrap.com/icons/pause-btn/ -->
55
+ <symbol id="pause" viewBox="0 0 16 16">
56
+ <path d="M6 3.5a.5.5 0 0 1 .5.5v8a.5.5 0 0 1-1 0V4a.5.5 0 0 1 .5-.5zm4 0a.5.5 0 0 1 .5.5v8a.5.5 0 0 1-1 0V4a.5.5 0 0 1 .5-.5z" />
57
+ </symbol>
58
+ <!-- https://icons.getbootstrap.com/icons/play/ -->
59
+ <symbol id="play" viewBox="0 0 16 16">
60
+ <path d="M10.804 8 5 4.633v6.734L10.804 8zm.792-.696a.802.802 0 0 1 0 1.392l-6.363 3.692C4.713 12.69 4 12.345 4 11.692V4.308c0-.653.713-.998 1.233-.696l6.363 3.692z" />
61
+ </symbol>
62
+ <!-- https://icons.getbootstrap.com/icons/skip-forward/ -->
63
+ <symbol id="skip_forward" viewBox="0 0 16 16">
64
+ <path d="M15.5 3.5a.5.5 0 0 1 .5.5v8a.5.5 0 0 1-1 0V8.752l-6.267 3.636c-.52.302-1.233-.043-1.233-.696v-2.94l-6.267 3.636C.713 12.69 0 12.345 0 11.692V4.308c0-.653.713-.998 1.233-.696L7.5 7.248v-2.94c0-.653.713-.998 1.233-.696L15 7.248V4a.5.5 0 0 1 .5-.5zM1 4.633v6.734L6.804 8 1 4.633zm7.5 0v6.734L14.304 8 8.5 4.633z" />
65
+ </symbol>
66
+ <!-- https://icons.getbootstrap.com/icons/stop/ -->
67
+ <symbol id="stop" viewBox="0 0 16 16">
68
+ <path d="M3.5 5A1.5 1.5 0 0 1 5 3.5h6A1.5 1.5 0 0 1 12.5 5v6a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 11V5zM5 4.5a.5.5 0 0 0-.5.5v6a.5.5 0 0 0 .5.5h6a.5.5 0 0 0 .5-.5V5a.5.5 0 0 0-.5-.5H5z" />
69
+ </symbol>
70
+ <!-- https://icons.getbootstrap.com/icons/sun-fill/ -->
71
+ <symbol id="sun_fill" viewBox="0 0 16 16">
72
+ <path d="M8 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13zm8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5zM3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8zm10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0zm-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0zm9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707zM4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708z" />
73
+ </symbol>
74
+ <!-- https://icons.getbootstrap.com/icons/trash/ -->
75
+ <symbol id="trash" viewBox="0 0 16 16">
76
+ <path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
77
+ <path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z" />
78
+ </symbol>
79
+ </svg>
@@ -0,0 +1,15 @@
1
+ import { Controller } from "stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = [ "source", "button" ]
5
+
6
+ copy() {
7
+ navigator.clipboard.writeText(this.sourceTarget.dataset.url ?? this.sourceTarget.value).then(() => {
8
+ const originalText = this.buttonTarget.textContent;
9
+ this.buttonTarget.textContent = 'Copied!';
10
+ setTimeout(() => {
11
+ this.buttonTarget.textContent = originalText;
12
+ }, 2000);
13
+ });
14
+ }
15
+ }
@@ -0,0 +1,60 @@
1
+ // hello_controller.js
2
+ import { Controller } from "stimulus"
3
+ import { Player } from 'rrweb-player';
4
+
5
+ export default class extends Controller {
6
+ static values = {
7
+ events: { type: Array, default: [] },
8
+ }
9
+
10
+ static targets = [ "player", "events", "linkUrl" ]
11
+
12
+ connect() {
13
+ this.player = new Player({
14
+ target: this.playerTarget,
15
+ props: {
16
+ events: this.eventsValue,
17
+ liveMode: true,
18
+ }
19
+ });
20
+
21
+ const urlParams = new URLSearchParams(window.location.search);
22
+ const time = urlParams.get('t');
23
+ if (time) {
24
+ const seconds = parseInt(time);
25
+ this.player.getReplayer().play(seconds * 1000);
26
+ }
27
+
28
+ let lastSeconds = null;
29
+ this.player.addEventListener('ui-update-current-time', (event) => {
30
+ const seconds = Math.floor(event.payload / 1000);
31
+ if (seconds === lastSeconds) return;
32
+ lastSeconds = seconds;
33
+ const url = `${window.location.origin}${window.location.pathname}?t=${seconds}`;
34
+ this.linkUrlTarget.value = `?t=${seconds}`;
35
+ this.linkUrlTarget.dataset.url = url;
36
+ });
37
+
38
+ const playerElement = this.playerTarget.getElementsByClassName("rr-player")[0];
39
+ playerElement.style.width = "100%";
40
+ playerElement.style.height = null;
41
+ playerElement.style.float = "none"
42
+ playerElement.style["border-radius"] = "inherit";
43
+ playerElement.style["box-shadow"] = "none";
44
+ }
45
+
46
+ eventsTargetConnected(element) {
47
+ if (!this.player) return;
48
+
49
+ const events = JSON.parse(element.dataset.events);
50
+ events.forEach(event => {
51
+ this.player.addEvent(event);
52
+ });
53
+
54
+ element.remove();
55
+ }
56
+
57
+ disconnect() {
58
+ this.player?.$destroy();
59
+ }
60
+ }
@@ -0,0 +1,44 @@
1
+ // hello_controller.js
2
+ import { Controller } from "stimulus"
3
+ export default class extends Controller {
4
+ static targets = [ "dropdown", "button" ]
5
+
6
+ connect() {
7
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
8
+ const theme = localStorage.getItem('spectator_sport-theme');
9
+ if (!["light", "dark"].includes(theme)) {
10
+ this.setTheme(this.autoTheme());
11
+ }
12
+ });
13
+
14
+ this.setTheme(this.getStoredTheme() || 'light');
15
+ }
16
+
17
+ change(event) {
18
+ const theme = event.params.value;
19
+ localStorage.setItem('spectator_sport-theme', theme);
20
+ this.setTheme(theme);
21
+ }
22
+
23
+ setTheme(theme) {
24
+ document.documentElement.setAttribute('data-bs-theme', theme === 'auto' ? this.autoTheme() : theme);
25
+
26
+ this.buttonTargets.forEach((button) => {
27
+ button.classList.remove('active');
28
+ if (button.dataset.themeValueParam === theme) {
29
+ button.classList.add('active');
30
+ }
31
+ });
32
+
33
+ const svg = this.buttonTargets.filter(b => b.matches(".active"))[0]?.querySelector('svg');
34
+ this.dropdownTarget.querySelector('svg').outerHTML = svg.outerHTML;
35
+ }
36
+
37
+ getStoredTheme() {
38
+ return localStorage.getItem('spectator_sport-theme');
39
+ }
40
+
41
+ autoTheme() {
42
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
43
+ }
44
+ }