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.
- checksums.yaml +4 -4
- data/README.md +137 -11
- data/app/controllers/spectator_sport/dashboard/application_controller.rb +8 -0
- data/app/controllers/spectator_sport/dashboard/dashboards_controller.rb +10 -0
- data/app/controllers/spectator_sport/dashboard/frontends_controller.rb +59 -0
- data/app/controllers/spectator_sport/dashboard/session_windows_controller.rb +34 -0
- data/app/controllers/spectator_sport/dashboard/tags_controller.rb +15 -0
- data/app/controllers/spectator_sport/events_controller.rb +45 -0
- data/app/frontend/spectator_sport/dashboard/application.js +31 -0
- data/app/frontend/spectator_sport/dashboard/icons.svg +79 -0
- data/app/frontend/spectator_sport/dashboard/modules/clipboard_controller.js +15 -0
- data/app/frontend/spectator_sport/dashboard/modules/player_controller.js +60 -0
- data/app/frontend/spectator_sport/dashboard/modules/theme_controller.js +44 -0
- data/app/frontend/spectator_sport/dashboard/style.css +87 -0
- data/app/frontend/spectator_sport/dashboard/vendor/bootstrap/bootstrap.bundle.min.js +6 -0
- data/app/frontend/spectator_sport/dashboard/vendor/bootstrap/bootstrap.min.css +6 -0
- data/app/frontend/spectator_sport/dashboard/vendor/es_module_shims.js +1 -0
- data/app/frontend/spectator_sport/dashboard/vendor/rrweb-player/rrweb-player.min.css +2 -0
- data/app/frontend/spectator_sport/dashboard/vendor/rrweb-player/rrweb-player.min.js +25 -0
- data/app/frontend/spectator_sport/dashboard/vendor/stimulus.js +2408 -0
- data/app/frontend/spectator_sport/dashboard/vendor/turbo.js +37 -0
- data/app/helpers/spectator_sport/dashboard/application_helper.rb +7 -0
- data/app/helpers/spectator_sport/dashboard/icons_helper.rb +15 -0
- data/app/helpers/spectator_sport/script_helper.rb +12 -0
- data/app/models/spectator_sport/event.rb +63 -0
- data/app/models/spectator_sport/session.rb +6 -0
- data/app/models/spectator_sport/session_window.rb +11 -0
- data/app/models/spectator_sport/session_window_analysis.rb +11 -0
- data/app/models/spectator_sport/session_window_tag.rb +13 -0
- data/app/views/layouts/spectator_sport/dashboard/application.html.erb +26 -0
- data/app/views/spectator_sport/dashboard/dashboards/index.html.erb +54 -0
- data/app/views/spectator_sport/dashboard/session_windows/_more_events_frame.erb +1 -0
- data/app/views/spectator_sport/dashboard/session_windows/details.html.erb +22 -0
- data/app/views/spectator_sport/dashboard/session_windows/events.erb +13 -0
- data/app/views/spectator_sport/dashboard/session_windows/show.html.erb +35 -0
- data/app/views/spectator_sport/dashboard/tags/show.html.erb +30 -0
- data/app/views/spectator_sport/events/index.js +248 -0
- data/app/views/spectator_sport/shared/_navbar.erb +63 -0
- data/config/dashboard_routes.rb +15 -0
- data/config/git_worktree.rb +41 -0
- data/config/routes.rb +1 -0
- data/lib/spectator_sport/engine.rb +14 -0
- data/lib/spectator_sport/version.rb +1 -1
- metadata +41 -9
- data/app/assets/config/spectator_sport_manifest.js +0 -1
- data/app/assets/stylesheets/spectator_sport/application.css +0 -15
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 04ef1fe5b218478993467e2b24f8279ce6385978ae57800ecc27dd8a98afa9af
|
|
4
|
+
data.tar.gz: b3730a2d748d434c36264ae7018a3c40a217c16dca9ba12c2956143f01a535f7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
24
|
-
```
|
|
25
|
-
|
|
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
|
-
|
|
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,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
|
+
}
|