product_tours 1.0.0 → 1.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/AGENTS.md +2 -2
- data/CHANGELOG.md +45 -1
- data/README.md +33 -6
- data/Rakefile +9 -1
- data/SECURITY.md +2 -2
- data/app/controllers/product_tours/interactions_controller.rb +20 -2
- data/app/controllers/product_tours/tours_controller.rb +3 -0
- data/app/models/product_tours/tour.rb +59 -2
- data/app/views/product_tours/tours/_form.html.erb +15 -0
- data/app/views/product_tours/tours/_tour_panel.html.erb +6 -0
- data/config/locales/product_tours.ar.yml +1 -0
- data/config/locales/product_tours.bg.yml +1 -0
- data/config/locales/product_tours.bn.yml +1 -0
- data/config/locales/product_tours.de.yml +1 -0
- data/config/locales/product_tours.el.yml +1 -0
- data/config/locales/product_tours.en.yml +1 -0
- data/config/locales/product_tours.es.yml +1 -0
- data/config/locales/product_tours.fr.yml +1 -0
- data/config/locales/product_tours.hi.yml +1 -0
- data/config/locales/product_tours.hr.yml +1 -0
- data/config/locales/product_tours.id.yml +1 -0
- data/config/locales/product_tours.it.yml +1 -0
- data/config/locales/product_tours.ja.yml +1 -0
- data/config/locales/product_tours.ko.yml +1 -0
- data/config/locales/product_tours.lb.yml +1 -0
- data/config/locales/product_tours.nl.yml +1 -0
- data/config/locales/product_tours.pl.yml +1 -0
- data/config/locales/product_tours.pt.yml +1 -0
- data/config/locales/product_tours.ro.yml +1 -0
- data/config/locales/product_tours.ru.yml +1 -0
- data/config/locales/product_tours.th.yml +1 -0
- data/config/locales/product_tours.tr.yml +1 -0
- data/config/locales/product_tours.uk.yml +1 -0
- data/config/locales/product_tours.ur.yml +1 -0
- data/config/locales/product_tours.vi.yml +1 -0
- data/config/locales/product_tours.zh-CN.yml +1 -0
- data/docs/ractors.md +138 -0
- data/lib/generators/product_tours/install/templates/create_product_tours_tours.rb.tt +2 -0
- data/lib/generators/product_tours/upgrade/templates/add_player_media_to_product_tours_tours.rb.tt +17 -0
- data/lib/generators/product_tours/upgrade/upgrade_generator.rb +29 -0
- data/lib/product_tours/engine.rb +12 -0
- data/lib/product_tours/ractor_support.rb +519 -0
- data/lib/product_tours/version.rb +1 -1
- data/lib/product_tours/widget.js +13 -1
- data/lib/product_tours/widget.rb +3 -3
- data/lib/product_tours.rb +1 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9645f38a9a88cddf4cb0d47618fdda9501208b758c057c74a857ccba51eefd21
|
|
4
|
+
data.tar.gz: 3279fad69985b9fe6c3716bbe1f052854f63f6dbd4e6758b9da6a5838c9a6aba
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 752b2ca7338b6387836d595641bb004ccfe50eb2dcbfdfea132547b0e3f929ae13b3d69d262e52c94f39d5600b4a986795ef200effc92bd4c0fa57c24fee27c3
|
|
7
|
+
data.tar.gz: 5bfec7949de1cea9beebb1d95ec9b28867d999827587303cd2c486b0c1aefc08feda7146d7c4ddd05528324d558ffa854f43f12c9d62f05c4b756cc450303bd8
|
data/AGENTS.md
CHANGED
|
@@ -75,7 +75,7 @@ What a tutorial carries: a `key`, a title, an optional rich description, an opti
|
|
|
75
75
|
|
|
76
76
|
### Video providers
|
|
77
77
|
|
|
78
|
-
YouTube, Vimeo, Loom, Tella, Voomly, a direct MP4/WebM URL, or an uploaded file (needs Active Storage). Uploaded videos stream through the engine at `/product_tours/media/:id` — not a public blob URL. Do not build your own blob links.
|
|
78
|
+
YouTube, Vimeo, Loom, Tella, Voomly, a direct MP4/WebM URL, or an uploaded file (needs Active Storage). A direct or uploaded video can also take a poster and a WebVTT captions file in the tutorial's language (`poster_url`, `captions_url`); provider embeds keep their own. An app installed before 1.2 lacks those columns until it runs `bin/rails generate product_tours:upgrade` and migrates — the model's `poster_supported?`/`captions_supported?` say whether they exist. Uploaded videos stream through the engine at `/product_tours/media/:id` — not a public blob URL. Do not build your own blob links.
|
|
79
79
|
|
|
80
80
|
**The engine edits the app's Content Security Policy.** It appends the providers' embed hosts to `frame-src` (or to `default-src` when `frame-src` is unset) in an initializer that runs after the host's own. That is deliberate — an embed silently blocked by CSP is a bad first five minutes — but know it happens, and do not hand-add those `frame-src` entries yourself. If the app sets its CSP somewhere unusual (a middleware, a per-controller override), that is where a blocked embed will come from.
|
|
81
81
|
|
|
@@ -164,7 +164,7 @@ bundle exec rubocop # must be clean
|
|
|
164
164
|
BUNDLE_GEMFILE=gemfiles/rails_7.1.gemfile bundle exec rake test # 7.1, 7.2, 8.0, 8.1 in gemfiles/
|
|
165
165
|
```
|
|
166
166
|
|
|
167
|
-
Layout: `app/` controllers, `Tour`, dashboard views · `lib/product_tours/` config, widget JS, seeds, engine, CSP patch · `lib/generators/product_tours
|
|
167
|
+
Layout: `app/` controllers, `Tour`, dashboard views · `lib/product_tours/` config, widget JS, seeds, engine, CSP patch · `lib/generators/product_tours/` the install generator and the idempotent upgrade generator · `config/locales/` 26 locales · `test/` minitest with `test/dummy` as the host app.
|
|
168
168
|
|
|
169
169
|
Conventions this codebase holds to — follow them rather than the first thing that works:
|
|
170
170
|
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,48 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.2.0] - 2026-11-03
|
|
6
|
+
|
|
7
|
+
- **Direct and uploaded videos can carry a poster and captions.** A tutorial
|
|
8
|
+
takes a `poster_url` and a WebVTT `captions_url` in its own language; the
|
|
9
|
+
widget sets the poster and adds a captions track labeled with the language's
|
|
10
|
+
own name, from a new `captions_label` key in all 26 locales. Without a
|
|
11
|
+
poster, the player still seeks to an early frame. Provider embeds are
|
|
12
|
+
unchanged, and a poster or captions on one is a validation error rather than
|
|
13
|
+
a silently ignored field. The modal's video also gains an accessible name.
|
|
14
|
+
- Both fields accept a relative path or an HTTPS URL, nothing else. A `.vtt`
|
|
15
|
+
on another origin needs CORS, and the gem does not touch `media-src` or
|
|
16
|
+
`img-src`: the host's policy decides what loads.
|
|
17
|
+
- A new translation copies the poster and drops the captions, since the
|
|
18
|
+
source's captions are in the wrong language.
|
|
19
|
+
- **Costs an upgrade step for existing installs.** Fresh installs get both
|
|
20
|
+
columns. Installs from 1.0 or 1.1 run `bin/rails generate
|
|
21
|
+
product_tours:upgrade` and migrate; until they do, the model reports
|
|
22
|
+
`captions_supported?`/`poster_supported?` as false, the dashboard hides both
|
|
23
|
+
fields and everything else works as before. The upgrade migration checks
|
|
24
|
+
each column first and is reversible.
|
|
25
|
+
|
|
26
|
+
## [1.1.0] - 2026-10-18
|
|
27
|
+
|
|
28
|
+
- Add Rails edge CI and experimental Ruby 4.0+ Ractor request support. The host
|
|
29
|
+
must opt into `Rails.application.ractorize!` and use the upstream Rack/I18n
|
|
30
|
+
dependencies documented in `docs/ractors.md`.
|
|
31
|
+
- Exercise actual multipart uploads and private downloads in worker Ractors
|
|
32
|
+
when Active Storage is installed, alongside the optional-dependency-free host.
|
|
33
|
+
- With Action Text, dashboard/editor and tour-content responses run on the
|
|
34
|
+
main Ractor because Nokogiri is not Ractor safe. Those paths buffer request
|
|
35
|
+
bodies and responses and do not gain parallel execution; other paths stay
|
|
36
|
+
on workers.
|
|
37
|
+
- Prepare immutable widget assets and a frozen configuration snapshot at
|
|
38
|
+
Ractorization. Callbacks still run for each request, with isolated captures;
|
|
39
|
+
callbacks that capture mutable state fail at boot with the setting name.
|
|
40
|
+
Normal Rails boots retain mutable configuration and lazy asset loading.
|
|
41
|
+
- Bridge framework operations that are not yet Ractor safe (mounted URL
|
|
42
|
+
generation, digests/key derivation and attribute factories) to the main
|
|
43
|
+
Ractor. This adds dispatch overhead in Ractor mode; it is compatibility
|
|
44
|
+
support, not a claim of improved throughput. Existing Rails 7.1–8.1 installs
|
|
45
|
+
need no migration or initializer changes.
|
|
46
|
+
|
|
5
47
|
## [1.0.0] - 2026-08-11
|
|
6
48
|
|
|
7
49
|
- **The record is a tour, all the way down.** `ProductTours::Post` is now
|
|
@@ -200,7 +242,9 @@
|
|
|
200
242
|
- Removed generic tutorial duplication now that translations provide the only
|
|
201
243
|
intentional content-copying workflow.
|
|
202
244
|
|
|
203
|
-
[Unreleased]: https://github.com/yshmarov/product_tours/compare/v1.
|
|
245
|
+
[Unreleased]: https://github.com/yshmarov/product_tours/compare/v1.2.0...HEAD
|
|
246
|
+
[1.2.0]: https://github.com/yshmarov/product_tours/compare/v1.1.0...v1.2.0
|
|
247
|
+
[1.1.0]: https://github.com/yshmarov/product_tours/compare/v1.0.0...v1.1.0
|
|
204
248
|
[1.0.0]: https://github.com/yshmarov/product_tours/compare/v0.3.3...v1.0.0
|
|
205
249
|
[0.3.3]: https://github.com/yshmarov/product_tours/compare/v0.3.2...v0.3.3
|
|
206
250
|
[0.3.2]: https://github.com/yshmarov/product_tours/compare/v0.3.1...v0.3.2
|
data/README.md
CHANGED
|
@@ -150,8 +150,15 @@ YouTube, Vimeo, and Loom metadata comes from their oEmbed endpoints. Metadata is
|
|
|
150
150
|
best-effort: a timeout never prevents you from saving a valid URL. YouTube uses
|
|
151
151
|
`youtube-nocookie.com`; unsupported hosts and lookalike URLs fail closed.
|
|
152
152
|
|
|
153
|
-
Direct
|
|
154
|
-
|
|
153
|
+
Direct and uploaded videos play in the gem's own player, so they can carry a
|
|
154
|
+
**poster** and **captions**: a WebVTT file in the tutorial's language, labeled
|
|
155
|
+
with that language's own name in the player's captions menu. Both take a path in
|
|
156
|
+
your app or an HTTPS URL. Serve the `.vtt` from your app's origin; a file on
|
|
157
|
+
another origin needs CORS, and your `media-src`/`img-src` policy must allow
|
|
158
|
+
whatever origin you use. Without a poster, the player seeks to an early frame
|
|
159
|
+
instead of showing an empty box. Provider embeds keep their own poster and
|
|
160
|
+
captions. A new translation starts without captions, because the source's file
|
|
161
|
+
is in the wrong language.
|
|
155
162
|
|
|
156
163
|
<details>
|
|
157
164
|
<summary><b>Upload videos or add rich descriptions</b></summary>
|
|
@@ -344,10 +351,11 @@ The following are the model and integration contracts that 1.x will keep
|
|
|
344
351
|
stable under semantic versioning:
|
|
345
352
|
|
|
346
353
|
- `ProductTours::Tour`, including `key`, `locale`, `status`, `title`,
|
|
347
|
-
`video_url`, `video_metadata`, `
|
|
348
|
-
`action_tour_key`, timestamps, `STATUSES`, status
|
|
349
|
-
`newest_first`, `action_tour`, `description_supported?`,
|
|
350
|
-
`video_upload_supported?`,
|
|
354
|
+
`video_url`, `video_metadata`, `poster_url`, `captions_url`, `action_label`,
|
|
355
|
+
`action_url`, `action_tour_key`, timestamps, `STATUSES`, status
|
|
356
|
+
methods/scopes, `newest_first`, `action_tour`, `description_supported?`,
|
|
357
|
+
`video_upload_supported?`, `poster_supported?`, `captions_supported?`, and
|
|
358
|
+
optional rich-description/video behavior.
|
|
351
359
|
- `ProductTours.configure` and the documented configuration options.
|
|
352
360
|
- `mount_product_tours`, `product_tours_tag`, and `data-product-tour="key"`.
|
|
353
361
|
- The tutorial key grammar and per-locale uniqueness contract.
|
|
@@ -363,6 +371,19 @@ Dashboard controllers, view partials, CSS classes, generated HTML, and widget
|
|
|
363
371
|
implementation objects are private. Incompatible changes to the public list
|
|
364
372
|
above wait for a new major version; a deprecation normally ships first.
|
|
365
373
|
|
|
374
|
+
### Upgrading from 1.0 or 1.1
|
|
375
|
+
|
|
376
|
+
Posters and captions (1.2) need two new columns. Until you add them, the
|
|
377
|
+
dashboard simply does not offer those fields:
|
|
378
|
+
|
|
379
|
+
```bash
|
|
380
|
+
bin/rails generate product_tours:upgrade
|
|
381
|
+
bin/rails db:migrate
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
The upgrade migration checks each column before adding it, so it is safe on an
|
|
385
|
+
install that already has them.
|
|
386
|
+
|
|
366
387
|
### Upgrading from 0.3.x
|
|
367
388
|
|
|
368
389
|
Version 1.0.0 is the deliberate terminology break from the pre-1.0 API.
|
|
@@ -447,3 +468,9 @@ touch your own CSS — and migrations that follow your app's `primary_key_type`.
|
|
|
447
468
|
|
|
448
469
|
MIT. If it saved you a subscription, a
|
|
449
470
|
[⭐](https://github.com/yshmarov/product_tours) is a fair trade.
|
|
471
|
+
|
|
472
|
+
## Rails edge and Ractors
|
|
473
|
+
|
|
474
|
+
Rails edge is tested alongside Rails 7.1–8.1. Experimental Ruby 4.0+ Ractor hosting
|
|
475
|
+
requires additional upstream dependencies and freezes configuration at startup.
|
|
476
|
+
See [the Ractor hosting guide](docs/ractors.md) for callbacks, requirements and checks.
|
data/Rakefile
CHANGED
|
@@ -5,7 +5,7 @@ require 'rake/testtask'
|
|
|
5
5
|
|
|
6
6
|
Rake::TestTask.new(:test) do |task|
|
|
7
7
|
task.libs << 'test'
|
|
8
|
-
task.test_files = FileList['test/**/*_test.rb'].exclude('test/system/**/*')
|
|
8
|
+
task.test_files = FileList['test/**/*_test.rb'].exclude('test/system/**/*', 'test/ractor/**/*')
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
namespace :test do
|
|
@@ -17,3 +17,11 @@ namespace :test do
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
task default: :test
|
|
20
|
+
|
|
21
|
+
namespace :test do
|
|
22
|
+
desc 'Run isolated configuration and Rails edge Ractor request tests (Ruby 4.0+)'
|
|
23
|
+
task :ractor do
|
|
24
|
+
ruby '-Itest', 'test/ractor/configuration_test.rb'
|
|
25
|
+
ruby '-Itest', 'test/ractor/requests_test.rb'
|
|
26
|
+
end
|
|
27
|
+
end
|
data/SECURITY.md
CHANGED
|
@@ -25,8 +25,8 @@ which defaults to everyone because showing tutorials in production is the gem's
|
|
|
25
25
|
purpose. The dashboard is separately protected by `authorize_admin` and fails
|
|
26
26
|
closed outside development until the host explicitly grants access.
|
|
27
27
|
|
|
28
|
-
A tour can store its key, locale, status, title, video URL and metadata,
|
|
29
|
-
action label/destination, timestamps, optional Action Text description, and an
|
|
28
|
+
A tour can store its key, locale, status, title, video URL and metadata, poster and
|
|
29
|
+
captions URLs, primary action label/destination, timestamps, optional Action Text description, and an
|
|
30
30
|
optional Active Storage video. Uploaded videos stream through the engine's
|
|
31
31
|
authorization gate with private, non-stored responses; the route never redirects
|
|
32
32
|
to a reusable signed blob URL. Hosts still own the storage service, bucket
|
|
@@ -47,8 +47,7 @@ module ProductTours
|
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
def tour_payload(tour)
|
|
50
|
-
video = tour
|
|
51
|
-
video[:url] = media_path(tour) if video&.dig(:kind) == 'upload'
|
|
50
|
+
video = player_video(tour)
|
|
52
51
|
{
|
|
53
52
|
key: tour.key,
|
|
54
53
|
locale: tour.locale,
|
|
@@ -63,6 +62,25 @@ module ProductTours
|
|
|
63
62
|
}
|
|
64
63
|
end
|
|
65
64
|
|
|
65
|
+
def player_video(tour)
|
|
66
|
+
video = tour.resolved_video
|
|
67
|
+
return if video.nil?
|
|
68
|
+
|
|
69
|
+
video[:url] = media_path(tour) if video[:kind] == 'upload'
|
|
70
|
+
video[:poster] = video.delete(:poster_url)
|
|
71
|
+
if (captions_url = video.delete(:captions_url))
|
|
72
|
+
video[:captions] = { url: captions_url, srclang: tour.locale, label: captions_label(tour.locale) }
|
|
73
|
+
end
|
|
74
|
+
video.compact
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Named in its own language, as a player's captions menu lists them.
|
|
78
|
+
def captions_label(locale)
|
|
79
|
+
I18n.t(:captions_label, scope: :product_tours, locale: locale, default: locale.to_s)
|
|
80
|
+
rescue I18n::InvalidLocale
|
|
81
|
+
locale.to_s
|
|
82
|
+
end
|
|
83
|
+
|
|
66
84
|
def default_action_label(tour)
|
|
67
85
|
key = tour.action_tour_key.present? ? :next : :done
|
|
68
86
|
I18n.t(key, scope: :product_tours, default: key.to_s.humanize)
|
|
@@ -89,6 +89,7 @@ module ProductTours
|
|
|
89
89
|
translation = source.dup
|
|
90
90
|
translation.locale = locale
|
|
91
91
|
translation.status = 'draft'
|
|
92
|
+
translation.captions_url = nil if Tour.captions_supported?
|
|
92
93
|
if translation.action_tour_key.present? &&
|
|
93
94
|
!Tour.exists?(locale: locale, key: translation.action_tour_key)
|
|
94
95
|
translation.action_tour_key = nil
|
|
@@ -160,6 +161,8 @@ module ProductTours
|
|
|
160
161
|
permitted = %i[key status title video_url action_label action_url action_tour_key]
|
|
161
162
|
permitted << :description if Tour.description_supported?
|
|
162
163
|
permitted << :video if Tour.video_upload_supported?
|
|
164
|
+
permitted << :poster_url if Tour.poster_supported?
|
|
165
|
+
permitted << :captions_url if Tour.captions_supported?
|
|
163
166
|
params.require(:tour).permit(*permitted)
|
|
164
167
|
end
|
|
165
168
|
|
|
@@ -10,6 +10,13 @@ module ProductTours
|
|
|
10
10
|
|
|
11
11
|
enum :status, STATUSES.index_by(&:itself), default: :draft
|
|
12
12
|
|
|
13
|
+
# Explicit methods work in non-main Ractors; Rails' enum-generated
|
|
14
|
+
# predicates and bang methods are currently unisolated closures.
|
|
15
|
+
def draft? = status_for_database == 'draft'
|
|
16
|
+
def draft! = update!(status: 'draft')
|
|
17
|
+
def published? = status_for_database == 'published'
|
|
18
|
+
def published! = update!(status: 'published')
|
|
19
|
+
|
|
13
20
|
has_rich_text :description if respond_to?(:has_rich_text)
|
|
14
21
|
|
|
15
22
|
has_one_attached :video, service: ProductTours.config.storage_service if defined?(::ActiveStorage)
|
|
@@ -23,6 +30,8 @@ module ProductTours
|
|
|
23
30
|
validate :action_url_is_safe
|
|
24
31
|
validate :video_url_is_supported
|
|
25
32
|
validate :uploaded_file_is_video
|
|
33
|
+
validate :player_media_urls_are_safe
|
|
34
|
+
validate :player_media_has_a_player
|
|
26
35
|
|
|
27
36
|
before_validation :set_default_locale
|
|
28
37
|
before_validation :derive_video_metadata, if: :will_save_change_to_video_url?
|
|
@@ -43,12 +52,26 @@ module ProductTours
|
|
|
43
52
|
false
|
|
44
53
|
end
|
|
45
54
|
|
|
55
|
+
# Added in 1.2. An install from an earlier version has neither column until
|
|
56
|
+
# it runs `bin/rails generate product_tours:upgrade`, and works as before.
|
|
57
|
+
def self.captions_supported?
|
|
58
|
+
column_names.include?('captions_url')
|
|
59
|
+
rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid
|
|
60
|
+
false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.poster_supported?
|
|
64
|
+
column_names.include?('poster_url')
|
|
65
|
+
rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid
|
|
66
|
+
false
|
|
67
|
+
end
|
|
68
|
+
|
|
46
69
|
def uploaded_video?
|
|
47
70
|
self.class.video_upload_supported? && video.attached?
|
|
48
71
|
end
|
|
49
72
|
|
|
50
73
|
def resolved_video
|
|
51
|
-
return { kind: 'upload', provider: 'upload', url: nil } if uploaded_video?
|
|
74
|
+
return { kind: 'upload', provider: 'upload', url: nil, **player_media } if uploaded_video?
|
|
52
75
|
|
|
53
76
|
resolved = VideoResolver.resolve(video_url)
|
|
54
77
|
return if resolved.nil?
|
|
@@ -57,10 +80,16 @@ module ProductTours
|
|
|
57
80
|
kind: resolved.kind,
|
|
58
81
|
provider: resolved.provider,
|
|
59
82
|
url: resolved.url,
|
|
60
|
-
thumbnail_url: video_metadata.to_h['thumbnail_url']
|
|
83
|
+
thumbnail_url: video_metadata.to_h['thumbnail_url'],
|
|
84
|
+
**(resolved.direct? ? player_media : {})
|
|
61
85
|
}.compact
|
|
62
86
|
end
|
|
63
87
|
|
|
88
|
+
# The tour's own video player can show a poster and a captions track.
|
|
89
|
+
# Provider iframes cannot: their poster and captions are the provider's.
|
|
90
|
+
def captions_url = (self[:captions_url] if self.class.captions_supported?)
|
|
91
|
+
def poster_url = (self[:poster_url] if self.class.poster_supported?)
|
|
92
|
+
|
|
64
93
|
def action_tour
|
|
65
94
|
return if action_tour_key.blank?
|
|
66
95
|
|
|
@@ -118,6 +147,34 @@ module ProductTours
|
|
|
118
147
|
errors.add(:action_tour_key, 'must identify an existing tour in the same locale')
|
|
119
148
|
end
|
|
120
149
|
|
|
150
|
+
def player_media
|
|
151
|
+
{ poster_url: poster_url.presence, captions_url: captions_url.presence }.compact
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def player_media_urls_are_safe
|
|
155
|
+
{ captions_url: captions_url, poster_url: poster_url }.each do |attribute, value|
|
|
156
|
+
next if value.blank? || safe_media_url?(value)
|
|
157
|
+
|
|
158
|
+
errors.add(attribute, 'must be a relative path or an HTTPS URL')
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def player_media_has_a_player
|
|
163
|
+
return if captions_url.blank? && poster_url.blank?
|
|
164
|
+
return if uploaded_video? || VideoResolver.resolve(video_url)&.direct?
|
|
165
|
+
|
|
166
|
+
errors.add(:base, 'A poster and captions need a direct MP4/WebM URL or an uploaded video')
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def safe_media_url?(value)
|
|
170
|
+
return !value.start_with?('//') if value.start_with?('/')
|
|
171
|
+
|
|
172
|
+
uri = URI.parse(value)
|
|
173
|
+
uri.is_a?(URI::HTTPS) && uri.host.present? && uri.userinfo.nil?
|
|
174
|
+
rescue URI::InvalidURIError
|
|
175
|
+
false
|
|
176
|
+
end
|
|
177
|
+
|
|
121
178
|
def uploaded_file_is_video
|
|
122
179
|
return unless uploaded_video?
|
|
123
180
|
return if video.blob.content_type.to_s.start_with?('video/')
|
|
@@ -115,6 +115,21 @@
|
|
|
115
115
|
</div>
|
|
116
116
|
</div>
|
|
117
117
|
<% end %>
|
|
118
|
+
|
|
119
|
+
<% if ProductTours::Tour.poster_supported? %>
|
|
120
|
+
<div class="field">
|
|
121
|
+
<%= form.label :poster_url, t("product_tours.dashboard.poster_url", default: "Poster image URL") %>
|
|
122
|
+
<%= form.text_field :poster_url, placeholder: "/tutorials/billing-poster.webp" %>
|
|
123
|
+
<small><%= t("product_tours.dashboard.poster_hint", default: "Shown before playback. For direct and uploaded videos; providers show their own. A path in this app or an HTTPS URL.") %></small>
|
|
124
|
+
</div>
|
|
125
|
+
<% end %>
|
|
126
|
+
<% if ProductTours::Tour.captions_supported? %>
|
|
127
|
+
<div class="field">
|
|
128
|
+
<%= form.label :captions_url, t("product_tours.dashboard.captions_url", default: "Captions file URL (WebVTT)") %>
|
|
129
|
+
<%= form.text_field :captions_url, placeholder: "/tutorials/billing.en.vtt" %>
|
|
130
|
+
<small><%= t("product_tours.dashboard.captions_hint", default: "Captions in this tutorial's language, for direct and uploaded videos. Serve the .vtt from this app: a file on another origin needs CORS. A new translation starts without captions.") %></small>
|
|
131
|
+
</div>
|
|
132
|
+
<% end %>
|
|
118
133
|
</section>
|
|
119
134
|
|
|
120
135
|
<section class="form-section">
|
|
@@ -34,6 +34,12 @@
|
|
|
34
34
|
<dt><%= t("product_tours.dashboard.publication_status", default: "Publication status") %></dt>
|
|
35
35
|
<dd><span class="badge status-<%= tour.status %>"><%= tour.status.humanize %></span></dd>
|
|
36
36
|
<dt><%= t("product_tours.dashboard.video_url", default: "Video URL") %></dt><dd><%= tour.video_url.presence || "—" %></dd>
|
|
37
|
+
<% if ProductTours::Tour.poster_supported? %>
|
|
38
|
+
<dt><%= t("product_tours.dashboard.poster_url", default: "Poster image URL") %></dt><dd><%= tour.poster_url.presence || "—" %></dd>
|
|
39
|
+
<% end %>
|
|
40
|
+
<% if ProductTours::Tour.captions_supported? %>
|
|
41
|
+
<dt><%= t("product_tours.dashboard.captions_url", default: "Captions file URL (WebVTT)") %></dt><dd><%= tour.captions_url.presence || "—" %></dd>
|
|
42
|
+
<% end %>
|
|
37
43
|
<dt><%= t("product_tours.dashboard.action_label", default: "Action label") %></dt><dd><%= tour.action_label.presence || default_label %></dd>
|
|
38
44
|
<dt><%= t("product_tours.dashboard.action_url", default: "Action URL") %></dt><dd><%= tour.action_url.presence || "—" %></dd>
|
|
39
45
|
<dt><%= t("product_tours.dashboard.next_tour", default: "Next tutorial") %></dt>
|