product_tours 0.1.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fab25758d2c1f8d2c5671555ddfcc04a1c473c60271635ec1e1c191bf9791718
4
- data.tar.gz: 42217e9e5f0f89d25c5bebafb93ad3918e77933707d98cd4d3277f9cc0e3920e
3
+ metadata.gz: fd91edac94c4a143630f7ba27c3892e750fbce4ea6f4c59490389ceae46a3e63
4
+ data.tar.gz: ab9eac5f0d14a4548bd49b89ccb7ad195031db2967123be5637953f318a439b3
5
5
  SHA512:
6
- metadata.gz: 3f2943f106b77da806858a18c719bd63311ad744b1fe08f637a747cf4f0f6dddb89d5399afe53d121acd6bb81da0b9db5cf560c5a307de39d45fe1440c6adc0f
7
- data.tar.gz: 75b8e54316db23fecbe7ab4398993afd616893bd8d2bd75e3c4bf0b0c834b65e29f6a6d97ff0d2302736e3067fe2952509677def0bc2abb607a167f0b98e375c
6
+ metadata.gz: b92ad9c92502b29411417e0c98764068df9dc1b9f6cd73c6751887d263124beee0261f1deddb4ed75bac5ae0b09b137cf20ae6e00eb83cda1f0d790979b0987a
7
+ data.tar.gz: 835576cf29da8b9210d42565494a3595fa6d76c7816b7e0aaef05be5a2cb2574324a5cd1e3524879998896d56a4b6bc63e66e48de2458986c538eee3671b360e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ - **`config.admin_layout` now works on its own.** The dashboard's stylesheet and
6
+ script were declared in the gem's layout, so replacing that layout dropped
7
+ both and the dashboard rendered unstyled with its client-side behaviour dead.
8
+ They move into the views, so every layout gets them with nothing asked of the
9
+ host.
10
+ - **The dashboard stylesheet no longer claims selectors it does not own.** It
11
+ styled bare `*`, `html`, `body` and `a`, and its `.container`, `.card` and
12
+ `.tabs` are names other frameworks use too, so a host that did load it had its
13
+ own chrome restyled. Component rules now nest inside a `.pt-dashboard` wrapper
14
+ the views render, and every custom property is `--pt-` prefixed so it can
15
+ neither overwrite a host's nor be overwritten. The page-frame rules stay keyed
16
+ to the body classes only the gem's own layout sets.
17
+ - **Added `config.base_controller_class`.** Name the controller your own admin
18
+ inherits from and the dashboard adopts its layout, helpers, authentication and
19
+ request context — the things `admin_layout` cannot give you. It reparents the
20
+ dashboard only; the widget, tour-resolution and media endpoints stay on the
21
+ engine's public controller, so it can never demand a staff session from a
22
+ visitor. Default is unchanged.
23
+ - **Migrations follow the host's `primary_key_type`,** the same
24
+ `Rails.configuration.generators` lookup Rails' own Active Storage, Action Text
25
+ and Action Mailbox migrations do. A uuid-keyed app has a uuid
26
+ `active_storage_attachments.record_id`, so a bigint table here could never
27
+ hold a video: `attach` raised `NotNullViolation`. A host that set nothing gets
28
+ an identical migration to before.
29
+ - **Dropped the `id: /\d+/` constraint on the media route,** which was what
30
+ forced the table to be bigint. It was never load-bearing: every fixed-name
31
+ route is declared first.
32
+ - **Dropped the redundant `(locale)` index.** A B-tree serves any leftmost
33
+ prefix, so `(locale, key)` already covered it; it only cost write time and
34
+ disk. Existing installs keep theirs until they drop it:
35
+ `remove_index :product_tours_posts, :locale`.
36
+ - A `BackboneTest` now fails the build on any of the above regressing.
37
+
3
38
  ## [Unreleased]
4
39
 
5
40
  ## [0.1.2] - 2026-08-04
data/README.md CHANGED
@@ -226,7 +226,8 @@ Everything is optional — a development install works with zero config. In
226
226
  | --- | --- | --- |
227
227
  | `enabled` | everyone | Who can resolve and open published tutorials |
228
228
  | `authorize_admin` | development only | **Who can manage content at the mount path** |
229
- | `admin_layout` | gem layout | Render the dashboard inside your admin shell |
229
+ | `base_controller_class` | `ActionController::Base` | Controller the dashboard inherits — name your admin's and it adopts its layout, helpers and auth |
230
+ | `admin_layout` | gem layout | Just the shell, if you don't want the whole controller |
230
231
  | `storage_service` | app default | Named Active Storage service for uploaded videos |
231
232
  | `mount_path` | `/product_tours` | Keep in sync only when mounting the engine manually |
232
233
 
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module ProductTours
6
+ # Who is asking, which locale they are in, and the gates that answer both.
7
+ #
8
+ # A concern rather than inherited behaviour because the engine has two
9
+ # controller roots: the public endpoints hang off ActionController::Base, and
10
+ # the dashboard hangs off whatever the host set as `base_controller_class`.
11
+ module RequestContext
12
+ extend ActiveSupport::Concern
13
+
14
+ private
15
+
16
+ def product_tours_admin_layout
17
+ ProductTours.config.admin_layout
18
+ end
19
+
20
+ def require_admin
21
+ return if ProductTours.admin?(request)
22
+
23
+ render plain: 'Forbidden. Set ProductTours.config.authorize_admin to grant access.', status: :forbidden
24
+ end
25
+
26
+ def current_product_tours_locale
27
+ requested_locale = params[:locale].to_s
28
+ return requested_locale if I18n.available_locales.map(&:to_s).include?(requested_locale)
29
+
30
+ ProductTours.locale(request)
31
+ end
32
+
33
+ def find_post_by_locale(scope, key)
34
+ locales = [current_product_tours_locale, I18n.default_locale.to_s].uniq
35
+ posts = scope.where(locale: locales, key: key).index_by(&:locale)
36
+ locales.filter_map { |locale| posts[locale] }.first
37
+ end
38
+
39
+ def clean_page_url(value)
40
+ uri = URI.parse(value.to_s)
41
+ return unless uri.is_a?(URI::HTTP) && uri.host.present?
42
+
43
+ uri.query = nil
44
+ uri.fragment = nil
45
+ uri.to_s
46
+ rescue URI::InvalidURIError
47
+ nil
48
+ end
49
+
50
+ def unresolved_payload(key, reason)
51
+ {
52
+ key: key.to_s,
53
+ locale: current_product_tours_locale,
54
+ reason: reason.to_s,
55
+ page_url: clean_page_url(params[:page_url])
56
+ }
57
+ end
58
+
59
+ def handle_unresolved_trigger(key, reason)
60
+ payload = unresolved_payload(key, reason)
61
+ error = ProductTours::UnresolvedTriggerError.new(payload)
62
+ raise error if Rails.env.development? || Rails.env.test?
63
+
64
+ if defined?(Rails.error) && Rails.error.respond_to?(:report)
65
+ Rails.error.report(error, handled: true, context: payload)
66
+ else
67
+ Rails.logger.error("product_tours: #{error.message} #{payload.inspect}")
68
+ end
69
+ ActiveSupport::Notifications.instrument('product_tours.unresolved_trigger', payload)
70
+ render json: { error: 'unresolved_trigger', reason: reason.to_s }, status: :not_found
71
+ end
72
+ end
73
+ end
@@ -1,68 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'uri'
4
-
5
3
  module ProductTours
4
+ # Root of the engine's PUBLIC surface: widget.js, tour resolution, signals and
5
+ # media. These stay on a plain ActionController::Base deliberately — a visitor
6
+ # loading a tour must not be routed through a host's admin controller, which
7
+ # would demand a staff session for the widget.
8
+ #
9
+ # The dashboard's root is DashboardController, and that is where
10
+ # `config.base_controller_class` applies.
6
11
  class ApplicationController < ActionController::Base
7
- protect_from_forgery with: :exception
8
-
9
- private
10
-
11
- def product_tours_admin_layout
12
- ProductTours.config.admin_layout
13
- end
14
-
15
- def require_admin
16
- return if ProductTours.admin?(request)
17
-
18
- render plain: 'Forbidden. Set ProductTours.config.authorize_admin to grant access.', status: :forbidden
19
- end
20
-
21
- def current_product_tours_locale
22
- requested_locale = params[:locale].to_s
23
- return requested_locale if I18n.available_locales.map(&:to_s).include?(requested_locale)
12
+ include RequestContext
24
13
 
25
- ProductTours.locale(request)
26
- end
27
-
28
- def find_post_by_locale(scope, key)
29
- locales = [current_product_tours_locale, I18n.default_locale.to_s].uniq
30
- posts = scope.where(locale: locales, key: key).index_by(&:locale)
31
- locales.filter_map { |locale| posts[locale] }.first
32
- end
33
-
34
- def clean_page_url(value)
35
- uri = URI.parse(value.to_s)
36
- return unless uri.is_a?(URI::HTTP) && uri.host.present?
37
-
38
- uri.query = nil
39
- uri.fragment = nil
40
- uri.to_s
41
- rescue URI::InvalidURIError
42
- nil
43
- end
44
-
45
- def unresolved_payload(key, reason)
46
- {
47
- key: key.to_s,
48
- locale: current_product_tours_locale,
49
- reason: reason.to_s,
50
- page_url: clean_page_url(params[:page_url])
51
- }
52
- end
53
-
54
- def handle_unresolved_trigger(key, reason)
55
- payload = unresolved_payload(key, reason)
56
- error = ProductTours::UnresolvedTriggerError.new(payload)
57
- raise error if Rails.env.development? || Rails.env.test?
58
-
59
- if defined?(Rails.error) && Rails.error.respond_to?(:report)
60
- Rails.error.report(error, handled: true, context: payload)
61
- else
62
- Rails.logger.error("product_tours: #{error.message} #{payload.inspect}")
63
- end
64
- ActiveSupport::Notifications.instrument('product_tours.unresolved_trigger', payload)
65
- render json: { error: 'unresolved_trigger', reason: reason.to_s }, status: :not_found
66
- end
14
+ protect_from_forgery with: :exception
67
15
  end
68
16
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProductTours
4
+ # Root of the STAFF surface: the post editor and its list.
5
+ #
6
+ # Inherits from `config.base_controller_class` — by default a plain
7
+ # ActionController::Base, which is why `authorize_admin` exists. Point it at
8
+ # the controller your own admin already inherits from and the dashboard picks
9
+ # up that stack wholesale: your layout, your helpers, your authentication, and
10
+ # whatever request context your before_actions establish. `config.admin_layout`
11
+ # only ever solved the first of those.
12
+ #
13
+ # Only the dashboard hangs off it. The widget's endpoints stay on
14
+ # ApplicationController, so wiring an admin base controller here can never
15
+ # demand a staff session from a visitor being shown a tour.
16
+ class DashboardController < ProductTours.base_controller
17
+ include RequestContext
18
+
19
+ # A host base controller brings its own layout, and declaring one here would
20
+ # override it. So the gem only claims the layout when it owns the decision:
21
+ # no host base controller, or a host that named an `admin_layout` explicitly.
22
+ layout :product_tours_admin_layout unless superclass != ActionController::Base &&
23
+ ProductTours.config.admin_layout ==
24
+ Configuration::DEFAULT_ADMIN_LAYOUT
25
+
26
+ before_action :require_admin
27
+
28
+ # A host base controller has configured CSRF already; declaring it twice
29
+ # would run the check twice.
30
+ protect_from_forgery with: :exception if superclass == ActionController::Base
31
+ end
32
+ end
@@ -1,11 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ProductTours
4
- class PostsController < ApplicationController
4
+ class PostsController < DashboardController
5
5
  PER_PAGE = 50
6
6
 
7
- layout :product_tours_admin_layout
8
- before_action :require_admin
9
7
  before_action :set_post,
10
8
  only: %i[show edit update destroy refresh_video_metadata add_translation publish unpublish]
11
9
  before_action :load_linkable_posts, only: %i[new create edit update]
@@ -5,16 +5,17 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <%= csrf_meta_tags %>
7
7
  <%= csp_meta_tag %>
8
- <%= stylesheet_link_tag "#{dashboard_stylesheet_path}?v=#{ProductTours::Widget.dashboard_stylesheet_fingerprint}",
9
- "data-turbo-track": "reload" %>
10
- <%= javascript_include_tag "#{dashboard_script_path}?v=#{ProductTours::Widget.dashboard_fingerprint}",
11
- "data-turbo-track": "reload", defer: true, nonce: true %>
8
+ <%# The dashboard's stylesheet and script are declared by the views
9
+ (product_tours/shared/_dashboard), not here, so they survive a host
10
+ replacing this layout via config.admin_layout. %>
12
11
  </head>
13
- <body class="<%= content_for?(:body_class) ? yield(:body_class) : '' %>">
14
- <div class="container">
15
- <% if notice.present? %><div class="flash notice"><%= notice %></div><% end %>
16
- <% if alert.present? %><div class="flash alert"><%= alert %></div><% end %>
17
- <%= yield %>
12
+ <body class="pt-page <%= content_for?(:body_class) ? yield(:body_class) : '' %>">
13
+ <div class="pt-dashboard">
14
+ <div class="container">
15
+ <% if notice.present? %><div class="flash notice"><%= notice %></div><% end %>
16
+ <% if alert.present? %><div class="flash alert"><%= alert %></div><% end %>
17
+ <%= yield %>
18
+ </div>
18
19
  </div>
19
20
  </body>
20
21
  </html>
@@ -1,5 +1,7 @@
1
1
  <% content_for :body_class, "pt-form-page" %>
2
2
 
3
- <div class="breadcrumb"><%= link_to t("product_tours.dashboard.title", default: "Product tours"), posts_path %> / <%= link_to @post.title, post_path(@post) %> / <%= t("product_tours.dashboard.edit", default: "Edit") %></div>
4
- <h1><%= t("product_tours.dashboard.edit_post", default: "Edit tutorial") %></h1>
5
- <%= render "form", post: @post %>
3
+ <%= render layout: 'product_tours/shared/dashboard' do %>
4
+ <div class="breadcrumb"><%= link_to t("product_tours.dashboard.title", default: "Product tours"), posts_path %> / <%= link_to @post.title, post_path(@post) %> / <%= t("product_tours.dashboard.edit", default: "Edit") %></div>
5
+ <h1><%= t("product_tours.dashboard.edit_post", default: "Edit tutorial") %></h1>
6
+ <%= render "form", post: @post %>
7
+ <% end %>
@@ -1,92 +1,94 @@
1
1
  <% content_for :body_class, "pt-index" %>
2
2
 
3
- <div class="page-head">
4
- <h1><%= t("product_tours.dashboard.title", default: "Product tours") %></h1>
5
- <%= link_to t("product_tours.dashboard.new_post", default: "New product tour"), new_post_path, class: "button primary" %>
6
- </div>
3
+ <%= render layout: 'product_tours/shared/dashboard' do %>
4
+ <div class="page-head">
5
+ <h1><%= t("product_tours.dashboard.title", default: "Product tours") %></h1>
6
+ <%= link_to t("product_tours.dashboard.new_post", default: "New product tour"), new_post_path, class: "button primary" %>
7
+ </div>
7
8
 
8
- <div class="dashboard-shell <%= "has-selected" if @selected_post %>">
9
- <aside class="dashboard-sidebar" aria-label="<%= t("product_tours.dashboard.posts", default: "Tutorials") %>">
10
- <nav class="tabs">
11
- <% ProductTours::Post::DASHBOARD_STATUSES.each do |status| %>
12
- <%= link_to posts_path(status: status, q: @query),
13
- class: ("active" if status == @status) do %>
14
- <%= status.humanize %>
15
- <span class="count"><%= @counts.fetch(status, 0) %></span>
9
+ <div class="dashboard-shell <%= "has-selected" if @selected_post %>">
10
+ <aside class="dashboard-sidebar" aria-label="<%= t("product_tours.dashboard.posts", default: "Tutorials") %>">
11
+ <nav class="tabs">
12
+ <% ProductTours::Post::DASHBOARD_STATUSES.each do |status| %>
13
+ <%= link_to posts_path(status: status, q: @query),
14
+ class: ("active" if status == @status) do %>
15
+ <%= status.humanize %>
16
+ <span class="count"><%= @counts.fetch(status, 0) %></span>
17
+ <% end %>
16
18
  <% end %>
17
- <% end %>
18
- </nav>
19
+ </nav>
19
20
 
20
- <%= form_with url: posts_path, method: :get, class: "filters" do |form| %>
21
- <%= form.hidden_field :status, value: @status %>
22
- <%= form.search_field :q, value: @query,
23
- list: "product-tour-key-options",
24
- placeholder: t("product_tours.dashboard.search_key", default: "Search key"),
25
- aria: { label: t("product_tours.dashboard.search", default: "Search") } %>
26
- <datalist id="product-tour-key-options">
27
- <% @keys.each do |key| %>
28
- <%= tag.option value: key %>
21
+ <%= form_with url: posts_path, method: :get, class: "filters" do |form| %>
22
+ <%= form.hidden_field :status, value: @status %>
23
+ <%= form.search_field :q, value: @query,
24
+ list: "product-tour-key-options",
25
+ placeholder: t("product_tours.dashboard.search_key", default: "Search key"),
26
+ aria: { label: t("product_tours.dashboard.search", default: "Search") } %>
27
+ <datalist id="product-tour-key-options">
28
+ <% @keys.each do |key| %>
29
+ <%= tag.option value: key %>
30
+ <% end %>
31
+ </datalist>
32
+ <%= form.submit t("product_tours.dashboard.search", default: "Search") %>
33
+ <% if @query.present? %>
34
+ <%= link_to t("product_tours.dashboard.clear_filters", default: "Clear"), posts_path,
35
+ class: "button filters-clear" %>
29
36
  <% end %>
30
- </datalist>
31
- <%= form.submit t("product_tours.dashboard.search", default: "Search") %>
32
- <% if @query.present? %>
33
- <%= link_to t("product_tours.dashboard.clear_filters", default: "Clear"), posts_path,
34
- class: "button filters-clear" %>
35
37
  <% end %>
36
- <% end %>
37
38
 
38
- <div class="record-list">
39
- <% if @posts.any? %>
40
- <% @posts.each do |post| %>
41
- <% provider = post.resolved_video&.dig(:provider) %>
42
- <%= link_to posts_path(status: @status, q: @query, page: @page, post_id: post.id),
43
- class: ["record-row", ("active" if @selected_post&.id == post.id)].compact do %>
44
- <span class="record-main">
45
- <span class="record-topline">
46
- <span class="badge status-<%= post.status %>"><%= post.status.humanize %></span>
47
- </span>
48
- <span class="record-copy"><%= post.title %></span>
49
- <span class="muted record-meta">
50
- <code class="key"><%= post.key %></code>
51
- <% if provider %><span><%= provider.humanize %></span><% end %>
39
+ <div class="record-list">
40
+ <% if @posts.any? %>
41
+ <% @posts.each do |post| %>
42
+ <% provider = post.resolved_video&.dig(:provider) %>
43
+ <%= link_to posts_path(status: @status, q: @query, page: @page, post_id: post.id),
44
+ class: ["record-row", ("active" if @selected_post&.id == post.id)].compact do %>
45
+ <span class="record-main">
46
+ <span class="record-topline">
47
+ <span class="badge status-<%= post.status %>"><%= post.status.humanize %></span>
48
+ </span>
49
+ <span class="record-copy"><%= post.title %></span>
50
+ <span class="muted record-meta">
51
+ <code class="key"><%= post.key %></code>
52
+ <% if provider %><span><%= provider.humanize %></span><% end %>
53
+ </span>
52
54
  </span>
53
- </span>
54
- <span class="record-side" aria-hidden="true"><%= provider ? "▶" : "→" %></span>
55
+ <span class="record-side" aria-hidden="true"><%= provider ? "▶" : "→" %></span>
56
+ <% end %>
55
57
  <% end %>
58
+ <% else %>
59
+ <div class="empty"><%= t("product_tours.dashboard.empty", default: "Nothing here yet.") %></div>
56
60
  <% end %>
57
- <% else %>
58
- <div class="empty"><%= t("product_tours.dashboard.empty", default: "Nothing here yet.") %></div>
59
- <% end %>
60
- </div>
61
+ </div>
61
62
 
62
- <nav class="pager">
63
- <span>
64
- <% if @page > 1 %>
65
- <%= link_to t("product_tours.dashboard.newer", default: "← Newer"),
66
- posts_path(status: @status, q: @query, page: @page - 1) %>
67
- <% end %>
68
- </span>
69
- <span>
70
- <% if @more %>
71
- <%= link_to t("product_tours.dashboard.older", default: "Older →"),
72
- posts_path(status: @status, q: @query, page: @page + 1) %>
73
- <% end %>
74
- </span>
75
- </nav>
76
- </aside>
63
+ <nav class="pager">
64
+ <span>
65
+ <% if @page > 1 %>
66
+ <%= link_to t("product_tours.dashboard.newer", default: "← Newer"),
67
+ posts_path(status: @status, q: @query, page: @page - 1) %>
68
+ <% end %>
69
+ </span>
70
+ <span>
71
+ <% if @more %>
72
+ <%= link_to t("product_tours.dashboard.older", default: "Older →"),
73
+ posts_path(status: @status, q: @query, page: @page + 1) %>
74
+ <% end %>
75
+ </span>
76
+ </nav>
77
+ </aside>
77
78
 
78
- <main class="dashboard-detail" aria-label="<%= t("product_tours.dashboard.current_post", default: "Current tutorial") %>">
79
- <% if @selected_post %>
80
- <p class="mobile-back">
81
- <%= link_to t("product_tours.dashboard.back", default: "All tutorials"),
82
- posts_path(status: @status, q: @query, page: @page) %>
83
- </p>
84
- <%= render "post_panel", post: @selected_post, sidebar_context: true %>
85
- <% else %>
86
- <div class="empty-state">
87
- <h2><%= t("product_tours.dashboard.select_post", default: "Select a tutorial") %></h2>
88
- <p class="muted"><%= t("product_tours.dashboard.select_post_hint", default: "Open a tutorial to preview or edit it.") %></p>
89
- </div>
90
- <% end %>
91
- </main>
92
- </div>
79
+ <main class="dashboard-detail" aria-label="<%= t("product_tours.dashboard.current_post", default: "Current tutorial") %>">
80
+ <% if @selected_post %>
81
+ <p class="mobile-back">
82
+ <%= link_to t("product_tours.dashboard.back", default: "All tutorials"),
83
+ posts_path(status: @status, q: @query, page: @page) %>
84
+ </p>
85
+ <%= render "post_panel", post: @selected_post, sidebar_context: true %>
86
+ <% else %>
87
+ <div class="empty-state">
88
+ <h2><%= t("product_tours.dashboard.select_post", default: "Select a tutorial") %></h2>
89
+ <p class="muted"><%= t("product_tours.dashboard.select_post_hint", default: "Open a tutorial to preview or edit it.") %></p>
90
+ </div>
91
+ <% end %>
92
+ </main>
93
+ </div>
94
+ <% end %>
@@ -1,5 +1,7 @@
1
1
  <% content_for :body_class, "pt-form-page" %>
2
2
 
3
- <div class="breadcrumb"><%= link_to t("product_tours.dashboard.title", default: "Product tours"), posts_path %> / <%= t("product_tours.dashboard.new_post", default: "New product tour") %></div>
4
- <h1><%= t("product_tours.dashboard.new_post", default: "New product tour") %></h1>
5
- <%= render "form", post: @post %>
3
+ <%= render layout: 'product_tours/shared/dashboard' do %>
4
+ <div class="breadcrumb"><%= link_to t("product_tours.dashboard.title", default: "Product tours"), posts_path %> / <%= t("product_tours.dashboard.new_post", default: "New product tour") %></div>
5
+ <h1><%= t("product_tours.dashboard.new_post", default: "New product tour") %></h1>
6
+ <%= render "form", post: @post %>
7
+ <% end %>
@@ -1,5 +1,7 @@
1
1
  <% content_for :body_class, "pt-show" %>
2
2
 
3
- <p class="breadcrumb"><%= link_to t("product_tours.dashboard.title", default: "Product tours"), posts_path %> / <%= @post.title %></p>
3
+ <%= render layout: 'product_tours/shared/dashboard' do %>
4
+ <p class="breadcrumb"><%= link_to t("product_tours.dashboard.title", default: "Product tours"), posts_path %> / <%= @post.title %></p>
4
5
 
5
- <%= render "post_panel", post: @post, sidebar_context: false %>
6
+ <%= render "post_panel", post: @post, sidebar_context: false %>
7
+ <% end %>
@@ -0,0 +1,16 @@
1
+ <%# The dashboard's own shell, rendered by the views rather than the layout.
2
+ That is deliberate: `config.admin_layout` and `config.base_controller_class`
3
+ both let a host replace the layout, and assets declared in a layout the host
4
+ replaces simply vanish — the dashboard then renders unstyled with its
5
+ client-side behaviour dead. Declaring them here means every layout works,
6
+ host or gem, and a host has nothing to wire up.
7
+
8
+ The `pt-dashboard` wrapper is what scopes dashboard.css (see the comment at
9
+ the top of that file), so it has to enclose the content, not precede it. %>
10
+ <%= stylesheet_link_tag "#{dashboard_stylesheet_path}?v=#{ProductTours::Widget.dashboard_stylesheet_fingerprint}",
11
+ "data-turbo-track": "reload" %>
12
+ <%= javascript_include_tag "#{dashboard_script_path}?v=#{ProductTours::Widget.dashboard_fingerprint}",
13
+ "data-turbo-track": "reload", defer: true, nonce: true %>
14
+ <div class="pt-dashboard">
15
+ <%= yield %>
16
+ </div>
data/config/routes.rb CHANGED
@@ -10,7 +10,11 @@ ProductTours::Engine.routes.draw do
10
10
  post :signal, to: 'tours#signal'
11
11
  end
12
12
 
13
- get 'media/:id', to: 'media#show', as: :media, constraints: { id: /\d+/ }
13
+ # No `id: /\d+/` constraint: it made this route bigint-only, which forced the
14
+ # table to be bigint too, and a uuid-keyed host could then never attach a video
15
+ # (its active_storage_attachments.record_id is a uuid column). Every
16
+ # fixed-name route above is declared first, so ordering already disambiguates.
17
+ get 'media/:id', to: 'media#show', as: :media
14
18
 
15
19
  resources :posts do
16
20
  post :refresh_video_metadata, on: :member
@@ -2,11 +2,13 @@
2
2
 
3
3
  require 'rails/generators'
4
4
  require 'rails/generators/active_record'
5
+ require_relative '../migration_helpers'
5
6
 
6
7
  module ProductTours
7
8
  module Generators
8
9
  class InstallGenerator < Rails::Generators::Base
9
10
  include ActiveRecord::Generators::Migration
11
+ include MigrationHelpers
10
12
 
11
13
  source_root File.expand_path('templates', __dir__)
12
14
  desc 'Installs product_tours: initializer, posts migration, and engine mount.'
@@ -33,12 +35,6 @@ module ProductTours
33
35
  say 'Run `bin/rails active_storage:install` for uploads and '
34
36
  say '`bin/rails action_text:install` for rich descriptions.\n'
35
37
  end
36
-
37
- private
38
-
39
- def migration_version
40
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
41
- end
42
38
  end
43
39
  end
44
40
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  class CreateProductToursPosts < ActiveRecord::Migration<%= migration_version %>
4
4
  def change
5
- create_table :product_tours_posts do |t|
5
+ create_table :product_tours_posts<%= primary_key_type_option %> do |t|
6
6
  t.string :key, null: false
7
7
  t.string :locale, null: false
8
8
  t.string :status, null: false, default: "draft"
@@ -17,7 +17,6 @@ class CreateProductToursPosts < ActiveRecord::Migration<%= migration_version %>
17
17
  end
18
18
 
19
19
  add_index :product_tours_posts, %i[locale key], unique: true
20
- add_index :product_tours_posts, :locale
21
20
  add_index :product_tours_posts, :status
22
21
 
23
22
  reversible do |direction|
@@ -8,6 +8,14 @@ ProductTours.configure do |config|
8
8
  # config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
9
9
 
10
10
  # Render the dashboard in your own admin shell.
11
+ # Two ways to put the dashboard inside an admin you already have.
12
+ #
13
+ # The whole stack — your layout, helpers, authentication and any request
14
+ # context your before_actions set up. Only the dashboard inherits it; the
15
+ # widget's endpoints stay public, so this cannot gate a visitor's tour.
16
+ # config.base_controller_class = "Admin::BaseController"
17
+ #
18
+ # Or the layout alone:
11
19
  # config.admin_layout = "admin/application"
12
20
 
13
21
  # Tutorials resolve in the page's current I18n.locale and fall back to
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProductTours
4
+ module Generators
5
+ # Shared bits every migration-writing generator needs.
6
+ module MigrationHelpers
7
+ private
8
+
9
+ def migration_version
10
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
11
+ end
12
+
13
+ # Follow the host's own key type instead of forcing bigint. An app that
14
+ # keys its models with uuids has a uuid
15
+ # `active_storage_attachments.record_id`, so a bigint table here could
16
+ # never take a video attachment: the foreign key has nowhere to point and
17
+ # `attach` raises NotNullViolation.
18
+ #
19
+ # Same lookup Rails' own Active Storage, Action Text and Action Mailbox
20
+ # migrations do, so a host that set it once gets consistent tables from
21
+ # all of them.
22
+ #
23
+ # Rendered as a `create_table` option rather than a bare value, because a
24
+ # template is expanded at generate time: emitting the method name would
25
+ # put `id: primary_key_type` in the migration, where nothing defines it.
26
+ # A host with no setting gets no option at all, so the migration reads
27
+ # exactly as it always did.
28
+ def primary_key_type_option
29
+ config = Rails.configuration.generators
30
+ type = config.options[config.orm][:primary_key_type]
31
+ type ? ", id: :#{type}" : ''
32
+ end
33
+ end
34
+ end
35
+ end
@@ -2,13 +2,33 @@
2
2
 
3
3
  module ProductTours
4
4
  class Configuration
5
+ # The gem's own dashboard layout. Compared against, so DashboardController
6
+ # can tell "the host left this alone" from "the host chose this".
7
+ DEFAULT_ADMIN_LAYOUT = 'product_tours/application'
8
+
5
9
  attr_accessor :enabled, :authorize_admin, :admin_layout, :mount_path,
6
10
  :storage_service
7
11
 
12
+ # The controller the DASHBOARD inherits from, as a String so it resolves
13
+ # lazily rather than at config time. Default: a plain
14
+ # 'ActionController::Base', where `authorize_admin` is the only gate.
15
+ #
16
+ # Name the controller your own admin already inherits from and the dashboard
17
+ # adopts that whole stack — layout, helpers, authentication, and any request
18
+ # context your before_actions set up. `admin_layout` covers only the layout,
19
+ # which leaves a host layout calling its own helpers to raise NameError under
20
+ # the engine's isolated namespace.
21
+ #
22
+ # Only the dashboard uses it. The widget's endpoints stay on the engine's own
23
+ # public controller, so an admin base controller here can never demand a
24
+ # staff session from a visitor being shown a tour.
25
+ attr_accessor :base_controller_class
26
+
8
27
  def initialize
9
28
  @enabled = ->(_request) { true }
10
29
  @authorize_admin = ->(_request) { Rails.env.development? }
11
- @admin_layout = 'product_tours/application'
30
+ @admin_layout = DEFAULT_ADMIN_LAYOUT
31
+ @base_controller_class = 'ActionController::Base'
12
32
  @mount_path = '/product_tours'
13
33
  @storage_service = nil
14
34
  end
@@ -1,196 +1,222 @@
1
+ /* Dashboard styles.
2
+ *
3
+ * Three layers, and the split is what lets a host replace the layout:
4
+ *
5
+ * :root custom properties, all `--pt-` prefixed so they can
6
+ * neither overwrite a host's nor be overwritten by them.
7
+ * .pt-page the page frame (was bare html/body). Only the gem's own
8
+ * layout puts this class on <body>, so inside a host admin
9
+ * these rules simply do not apply.
10
+ * .pt-dashboard everything else, nested. Names like .container, .card
11
+ * and .tabs are confined to the dashboard's own markup, so
12
+ * loading this file inside a host cannot restyle its chrome.
13
+ *
14
+ * Nothing here styles a bare `body`, `a`, `table` or `*` at the top level.
15
+ */
16
+
17
+
1
18
  :root {
2
19
  color-scheme: light dark;
3
- --bg: #f6f7f9;
4
- --surface: #ffffff;
5
- --text: #1c2024;
6
- --muted: #6b7280;
7
- --border: #e5e7eb;
8
- --accent: #2563eb;
9
- --accent-text: #ffffff;
10
- --draft: #6b7280;
11
- --published: #16a34a;
12
- --danger: #dc2626;
13
- --code: #f0f1f3;
20
+ --pt-bg: #f6f7f9;
21
+ --pt-surface: #ffffff;
22
+ --pt-text: #1c2024;
23
+ --pt-muted: #6b7280;
24
+ --pt-border: #e5e7eb;
25
+ --pt-accent: #2563eb;
26
+ --pt-accent-text: #ffffff;
27
+ --pt-draft: #6b7280;
28
+ --pt-published: #16a34a;
29
+ --pt-danger: #dc2626;
30
+ --pt-code: #f0f1f3;
14
31
  }
15
-
16
32
  @media (prefers-color-scheme: dark) {
17
33
  :root {
18
- --bg: #111418;
19
- --surface: #1a1f26;
20
- --text: #e6e8ea;
21
- --muted: #9aa2ab;
22
- --border: #2a313a;
23
- --accent: #3b82f6;
24
- --code: #232a33;
25
- }
34
+ --pt-bg: #111418;
35
+ --pt-surface: #1a1f26;
36
+ --pt-text: #e6e8ea;
37
+ --pt-muted: #9aa2ab;
38
+ --pt-border: #2a313a;
39
+ --pt-accent: #3b82f6;
40
+ --pt-code: #232a33;
26
41
  }
27
-
28
- * { box-sizing: border-box; }
29
- html { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; letter-spacing: 0; }
30
- body { margin: 0; min-height: 100vh; background: var(--bg); color: var(--text); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
31
- a { color: var(--accent); text-decoration: none; }
32
- a:hover { text-decoration: underline; }
33
- :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
34
- .container { max-width: 1020px; margin: 0 auto; padding: 24px 16px 64px; }
35
- h1 { margin: 0 0 16px; font-size: 22px; }
36
- h2 { margin: 0 0 12px; font-size: 16px; }
37
- .page-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
38
- .page-head h1 { margin: 0; }
39
- .muted { color: var(--muted); font-size: 13px; }
40
-
41
- button, .button {
42
- width: auto;
43
- display: inline-flex; align-items: center; justify-content: center; min-height: 38px; padding: 8px 14px;
44
- border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text);
45
- font: inherit; cursor: pointer;
46
42
  }
47
- button:hover, .button:hover { border-color: var(--muted); text-decoration: none; }
48
- button.primary, .button.primary { border-color: var(--accent); background: var(--accent); color: var(--accent-text); }
49
- button.danger, .button.danger { color: var(--danger); }
50
- button.small, .button.small { min-height: 32px; padding: 4px 10px; font-size: 13px; }
51
-
52
- .tabs { display: flex; gap: 6px; }
53
- .tabs a {
54
- position: relative; display: flex; flex: 1 1 0; align-items: center; justify-content: center; gap: 6px;
55
- min-height: 34px; padding: 5px 10px 8px; border-radius: 8px; color: var(--muted); font-weight: 600;
56
- }
57
- .tabs a:hover { background: var(--bg); color: var(--text); text-decoration: none; }
58
- .tabs a.active { color: var(--text); }
59
- .tabs a.active::after { content: ""; position: absolute; right: 20px; bottom: 0; left: 20px; height: 2px; border-radius: 999px; background: var(--accent); }
60
- .count { min-width: 20px; padding: 0 6px; border-radius: 999px; background: color-mix(in srgb, var(--muted) 14%, transparent); font-size: 12px; text-align: center; }
61
-
62
- .filters { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 8px; padding: 12px; border-bottom: 1px solid var(--border); }
63
- .filters input[type="search"] { min-width: 0; }
64
- input, select, textarea {
65
- width: 100%; min-height: 38px; padding: 7px 9px; border: 1px solid var(--border); border-radius: 8px;
66
- background: var(--surface); color: var(--text); font: inherit; font-size: 16px; letter-spacing: 0;
43
+ @media (max-width: 760px) {
44
+ .pt-page { overflow-x: hidden; font-size: 14px; }
45
+ .pt-index { overflow-y: auto; overflow-x: hidden; }
46
+ .pt-index, .pt-index .container { min-height: 100vh; height: auto; }
47
+ .pt-index .container { padding-bottom: 10px; }
48
+ .pt-dashboard {
49
+ & .container { padding: 14px 10px 24px; }
50
+ & .page-head { align-items: center; }
51
+ & .page-head .button { min-height: 36px; }
52
+ & .dashboard-shell { display: block; min-height: calc(100vh - 58px); }
53
+ & .dashboard-sidebar,
54
+ & .dashboard-detail { min-height: calc(100vh - 58px); }
55
+ & .dashboard-shell.has-selected .dashboard-sidebar { display: none; }
56
+ & .dashboard-shell:not(.has-selected) .dashboard-detail { display: none; }
57
+ & .record-list { overflow: visible; }
58
+ & .record-row { padding: 12px; }
59
+ & .record-meta { display: none; }
60
+ & .dashboard-detail .post-panel { min-height: calc(100vh - 64px); }
61
+ & .mobile-back { display: block; flex: 0 0 auto; margin: 4px 0 10px; font-size: 13px; }
62
+ & .post-panel,
63
+ & .detail-scroll,
64
+ & .card,
65
+ & dl,
66
+ & dd { min-width: 0; max-width: 100%; }
67
+ & .dashboard-detail .card { width: 100%; }
68
+ & dl { grid-template-columns: 1fr; gap: 3px; }
69
+ & dd { margin-bottom: 8px; }
70
+ & .action-picker,
71
+ & .video-source-picker { grid-template-columns: 1fr; }
72
+ & .form-card { padding: 0; }
73
+ }
67
74
  }
68
- textarea { resize: vertical; }
69
- input:focus, select:focus, textarea:focus { border-color: var(--accent); outline: 2px solid color-mix(in srgb, var(--accent) 22%, transparent); outline-offset: 0; }
70
-
71
- .card { overflow: hidden; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); }
72
- .card.pad { padding: 16px; overflow: visible; }
73
- .badge { display: inline-flex; padding: 2px 9px; border-radius: 999px; color: #ffffff; font-size: 12px; font-weight: 600; white-space: nowrap; }
74
- .badge.status-draft { background: var(--draft); }
75
- .badge.status-published { background: var(--published); }
76
- .badge.locale { border: 1px solid var(--border); background: transparent; color: var(--muted); }
77
- code.key { max-width: 100%; padding: 2px 7px; border-radius: 6px; background: var(--code); font: 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
78
- .flash, .errors { margin-bottom: 16px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); }
79
- .flash.notice { border-color: var(--published); }
80
- .flash.alert, .errors { border-color: var(--danger); }
81
- .errors ul { margin-bottom: 0; }
82
- .empty { padding: 48px 16px; color: var(--muted); text-align: center; }
83
- .empty-state { max-width: 320px; margin: auto; padding: 24px; text-align: center; }
84
- .empty-state h2 { margin: 0 0 6px; font-size: 18px; }
85
- .empty-state p { margin: 0; }
86
- .breadcrumb { margin: 0 0 12px; font-size: 13px; }
87
75
 
76
+ /* Page frame — the gem's own layout only. */
77
+ .pt-page { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; letter-spacing: 0; }
78
+ .pt-page { margin: 0; min-height: 100vh; background: var(--pt-bg); color: var(--pt-text); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
88
79
  .pt-index, .pt-index .container { height: 100vh; height: 100dvh; }
89
80
  .pt-index { overflow: hidden; }
90
81
  .pt-index .container { display: flex; max-width: 1280px; flex-direction: column; padding-bottom: 16px; }
91
82
  .pt-index .page-head { flex: 0 0 auto; }
92
- .dashboard-shell { display: grid; flex: 1 1 auto; grid-template-columns: minmax(330px, 410px) 1fr; gap: 16px; min-height: 0; }
93
- .dashboard-sidebar { display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); }
94
- .dashboard-sidebar .tabs { flex: 0 0 auto; margin: 12px 12px 0; }
95
- .record-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
96
- .record-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 13px 14px; border-bottom: 1px solid var(--border); color: var(--text); }
97
- .record-row:hover { background: var(--bg); text-decoration: none; }
98
- .record-row.active { background: color-mix(in srgb, var(--accent) 9%, var(--surface)); box-shadow: inset 3px 0 0 var(--accent); }
99
- .record-main { display: flex; min-width: 0; flex-direction: column; gap: 5px; }
100
- .record-topline, .record-meta { display: flex; align-items: center; gap: 8px; min-width: 0; }
101
- .record-copy { display: block; overflow: hidden; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
102
- .record-meta span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
103
- .record-side { align-self: center; color: var(--muted); }
104
- .pager { display: flex; flex: 0 0 auto; justify-content: space-between; min-height: 42px; padding: 10px 12px; border-top: 1px solid var(--border); }
105
- .dashboard-detail { display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; }
106
- .dashboard-detail .post-panel { flex: 1 1 auto; min-height: 0; }
107
- .dashboard-detail .detail-scroll { flex: 1 1 auto; overflow-y: auto; padding-right: 2px; }
108
- .mobile-back { display: none; }
109
-
110
- .post-panel { display: flex; min-width: 0; flex-direction: column; }
111
- .detail-scroll { display: flex; min-height: 0; flex-direction: column; gap: 12px; }
112
- .preview-card { width: 100%; }
113
- .video-preview { width: 100%; aspect-ratio: 16 / 9; margin-bottom: 16px; overflow: hidden; border-radius: 6px; background: #05070a; }
114
- .video-preview iframe, .video-preview video { display: block; width: 100%; height: 100%; border: 0; object-fit: contain; }
115
- .description-preview { color: var(--muted); overflow-wrap: anywhere; }
116
- .preview-action { display: flex; justify-content: flex-end; margin-top: 16px; }
117
- .details-card { width: 100%; }
118
- dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 16px; margin: 0; }
119
- dt { color: var(--muted); }
120
- dd { margin: 0; overflow-wrap: anywhere; }
121
- .refresh-button { margin-top: 14px; }
122
- .metadata { max-width: 100%; overflow-x: auto; margin: 14px 0 0; padding: 12px; border-radius: 6px; background: var(--code); font-size: 12px; }
123
- .translation-list { border-top: 1px solid var(--border); }
124
- .translation-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); }
125
- .translation-name { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 8px; }
126
- .translation-form { display: flex; align-items: flex-end; gap: 8px; margin-top: 16px; }
127
- .translation-form .field { flex: 1 1 auto; margin: 0; }
128
- .translation-complete { margin: 14px 0 0; }
129
- .actions, .form-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
130
- .actions { margin: 0; }
131
- .details-actions { margin-top: 16px; }
132
- .actions form { display: inline; }
133
- .form-actions input[type="submit"] { width: auto; }
134
-
135
83
  .pt-show { min-height: 100vh; overflow: auto; }
136
84
  .pt-show .post-panel { width: 100%; }
137
85
  .pt-show .detail-scroll { overflow: visible; }
138
86
  .pt-form-page .container { max-width: 820px; }
139
- .post-form { width: 100%; }
140
- .form-card { padding: 0; }
141
- .form-section { padding: 12px 0 4px; }
142
- .form-section + .form-section { margin-top: 4px; }
143
- .form-section h2 { margin-bottom: 12px; }
144
- .field { margin-bottom: 16px; }
145
- .field label { display: block; margin-bottom: 5px; font-weight: 600; }
146
- .field small { display: block; margin-top: 5px; color: var(--muted); }
147
- .section-intro { margin: -4px 0 12px; color: var(--muted); }
148
- .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
149
- .action-picker, .video-source-picker { display: grid; gap: 10px; min-width: 0; margin: 0 0 18px; padding: 0; border: 0; }
150
- .action-picker { grid-template-columns: repeat(3, 1fr); }
151
- .video-source-picker { grid-template-columns: repeat(2, 1fr); }
152
- .action-choice { display: flex; align-items: flex-start; gap: 10px; min-width: 0; margin: 0; padding: 12px; border: 1px solid var(--border); border-radius: 8px; cursor: pointer; }
153
- .action-choice:hover { border-color: var(--muted); }
154
- .action-choice.selected { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 7%, var(--surface)); box-shadow: 0 0 0 1px var(--accent); }
155
- .action-choice input { width: 18px; min-width: 18px; min-height: 18px; margin: 2px 0 0; }
156
- .action-choice span { min-width: 0; }
157
- .action-choice strong, .action-choice small { display: block; }
158
- .action-choice small { margin-top: 3px; color: var(--muted); font-weight: 400; line-height: 1.35; }
159
- .preview-action { align-items: center; gap: 12px; }
160
- .preview-back { margin-right: auto; color: var(--muted); }
161
- .checkbox { display: flex !important; align-items: center; gap: 8px; margin-top: 8px; font-weight: 400 !important; }
162
- .checkbox input { width: 18px; min-height: 18px; }
163
- .form-actions { padding-top: 12px; }
164
- .form-video-preview { margin: 2px 0 16px; }
165
- .form-video-preview .video-preview { margin-bottom: 6px; }
166
- .form-video-preview > small { color: var(--muted); }
167
- .rich-editor { overflow: hidden; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); }
168
- .rich-toolbar { display: flex; gap: 4px; padding: 7px; border-bottom: 1px solid var(--border); background: var(--bg); }
169
- .rich-toolbar button { width: 36px; height: 34px; min-height: 34px; padding: 0; border-color: transparent; background: transparent; font-weight: 600; }
170
- .rich-content { min-height: 160px; padding: 12px; outline: none; overflow-wrap: anywhere; }
171
- .rich-content:focus { box-shadow: inset 0 0 0 2px var(--accent); }
172
87
 
173
- @media (max-width: 760px) {
174
- body { overflow-x: hidden; font-size: 14px; }
175
- .container { padding: 14px 10px 24px; }
176
- .page-head { align-items: center; }
177
- .page-head .button { min-height: 36px; }
178
- .pt-index { overflow-y: auto; overflow-x: hidden; }
179
- .pt-index, .pt-index .container { min-height: 100vh; height: auto; }
180
- .pt-index .container { padding-bottom: 10px; }
181
- .dashboard-shell { display: block; min-height: calc(100vh - 58px); }
182
- .dashboard-sidebar, .dashboard-detail { min-height: calc(100vh - 58px); }
183
- .dashboard-shell.has-selected .dashboard-sidebar { display: none; }
184
- .dashboard-shell:not(.has-selected) .dashboard-detail { display: none; }
185
- .record-list { overflow: visible; }
186
- .record-row { padding: 12px; }
187
- .record-meta { display: none; }
188
- .dashboard-detail .post-panel { min-height: calc(100vh - 64px); }
189
- .mobile-back { display: block; flex: 0 0 auto; margin: 4px 0 10px; font-size: 13px; }
190
- .post-panel, .detail-scroll, .card, dl, dd { min-width: 0; max-width: 100%; }
191
- .dashboard-detail .card { width: 100%; }
192
- dl { grid-template-columns: 1fr; gap: 3px; }
193
- dd { margin-bottom: 8px; }
194
- .action-picker, .video-source-picker { grid-template-columns: 1fr; }
195
- .form-card { padding: 0; }
88
+ /* Dashboard components — any layout. */
89
+ .pt-dashboard {
90
+ & * { box-sizing: border-box; }
91
+ & a { color: var(--pt-accent); text-decoration: none; }
92
+ & a:hover { text-decoration: underline; }
93
+ & :focus-visible { outline: 2px solid var(--pt-accent); outline-offset: 2px; }
94
+ & .container { max-width: 1020px; margin: 0 auto; padding: 24px 16px 64px; }
95
+ & h1 { margin: 0 0 16px; font-size: 22px; }
96
+ & h2 { margin: 0 0 12px; font-size: 16px; }
97
+ & .page-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
98
+ & .page-head h1 { margin: 0; }
99
+ & .muted { color: var(--pt-muted); font-size: 13px; }
100
+ & button,
101
+ & .button { width: auto; display: inline-flex; align-items: center; justify-content: center; min-height: 38px; padding: 8px 14px; border: 1px solid var(--pt-border); border-radius: 8px; background: var(--pt-surface); color: var(--pt-text); font: inherit; cursor: pointer; }
102
+ & button:hover,
103
+ & .button:hover { border-color: var(--pt-muted); text-decoration: none; }
104
+ & button.primary,
105
+ & .button.primary { border-color: var(--pt-accent); background: var(--pt-accent); color: var(--pt-accent-text); }
106
+ & button.danger,
107
+ & .button.danger { color: var(--pt-danger); }
108
+ & button.small,
109
+ & .button.small { min-height: 32px; padding: 4px 10px; font-size: 13px; }
110
+ & .tabs { display: flex; gap: 6px; }
111
+ & .tabs a { position: relative; display: flex; flex: 1 1 0; align-items: center; justify-content: center; gap: 6px; min-height: 34px; padding: 5px 10px 8px; border-radius: 8px; color: var(--pt-muted); font-weight: 600; }
112
+ & .tabs a:hover { background: var(--pt-bg); color: var(--pt-text); text-decoration: none; }
113
+ & .tabs a.active { color: var(--pt-text); }
114
+ & .tabs a.active::after { content: ""; position: absolute; right: 20px; bottom: 0; left: 20px; height: 2px; border-radius: 999px; background: var(--pt-accent); }
115
+ & .count { min-width: 20px; padding: 0 6px; border-radius: 999px; background: color-mix(in srgb, var(--pt-muted) 14%, transparent); font-size: 12px; text-align: center; }
116
+ & .filters { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 8px; padding: 12px; border-bottom: 1px solid var(--pt-border); }
117
+ & .filters input[type="search"] { min-width: 0; }
118
+ & input,
119
+ & select,
120
+ & textarea { width: 100%; min-height: 38px; padding: 7px 9px; border: 1px solid var(--pt-border); border-radius: 8px; background: var(--pt-surface); color: var(--pt-text); font: inherit; font-size: 16px; letter-spacing: 0; }
121
+ & textarea { resize: vertical; }
122
+ & input:focus,
123
+ & select:focus,
124
+ & textarea:focus { border-color: var(--pt-accent); outline: 2px solid color-mix(in srgb, var(--pt-accent) 22%, transparent); outline-offset: 0; }
125
+ & .card { overflow: hidden; border: 1px solid var(--pt-border); border-radius: 12px; background: var(--pt-surface); }
126
+ & .card.pad { padding: 16px; overflow: visible; }
127
+ & .badge { display: inline-flex; padding: 2px 9px; border-radius: 999px; color: #ffffff; font-size: 12px; font-weight: 600; white-space: nowrap; }
128
+ & .badge.status-draft { background: var(--pt-draft); }
129
+ & .badge.status-published { background: var(--pt-published); }
130
+ & .badge.locale { border: 1px solid var(--pt-border); background: transparent; color: var(--pt-muted); }
131
+ & code.key { max-width: 100%; padding: 2px 7px; border-radius: 6px; background: var(--pt-code); font: 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
132
+ & .flash,
133
+ & .errors { margin-bottom: 16px; padding: 10px 12px; border: 1px solid var(--pt-border); border-radius: 8px; background: var(--pt-surface); }
134
+ & .flash.notice { border-color: var(--pt-published); }
135
+ & .flash.alert,
136
+ & .errors { border-color: var(--pt-danger); }
137
+ & .errors ul { margin-bottom: 0; }
138
+ & .empty { padding: 48px 16px; color: var(--pt-muted); text-align: center; }
139
+ & .empty-state { max-width: 320px; margin: auto; padding: 24px; text-align: center; }
140
+ & .empty-state h2 { margin: 0 0 6px; font-size: 18px; }
141
+ & .empty-state p { margin: 0; }
142
+ & .breadcrumb { margin: 0 0 12px; font-size: 13px; }
143
+ & .dashboard-shell { display: grid; flex: 1 1 auto; grid-template-columns: minmax(330px, 410px) 1fr; gap: 16px; min-height: 0; }
144
+ & .dashboard-sidebar { display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; border: 1px solid var(--pt-border); border-radius: 8px; background: var(--pt-surface); }
145
+ & .dashboard-sidebar .tabs { flex: 0 0 auto; margin: 12px 12px 0; }
146
+ & .record-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
147
+ & .record-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 13px 14px; border-bottom: 1px solid var(--pt-border); color: var(--pt-text); }
148
+ & .record-row:hover { background: var(--pt-bg); text-decoration: none; }
149
+ & .record-row.active { background: color-mix(in srgb, var(--pt-accent) 9%, var(--pt-surface)); box-shadow: inset 3px 0 0 var(--pt-accent); }
150
+ & .record-main { display: flex; min-width: 0; flex-direction: column; gap: 5px; }
151
+ & .record-topline,
152
+ & .record-meta { display: flex; align-items: center; gap: 8px; min-width: 0; }
153
+ & .record-copy { display: block; overflow: hidden; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
154
+ & .record-meta span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
155
+ & .record-side { align-self: center; color: var(--pt-muted); }
156
+ & .pager { display: flex; flex: 0 0 auto; justify-content: space-between; min-height: 42px; padding: 10px 12px; border-top: 1px solid var(--pt-border); }
157
+ & .dashboard-detail { display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; }
158
+ & .dashboard-detail .post-panel { flex: 1 1 auto; min-height: 0; }
159
+ & .dashboard-detail .detail-scroll { flex: 1 1 auto; overflow-y: auto; padding-right: 2px; }
160
+ & .mobile-back { display: none; }
161
+ & .post-panel { display: flex; min-width: 0; flex-direction: column; }
162
+ & .detail-scroll { display: flex; min-height: 0; flex-direction: column; gap: 12px; }
163
+ & .preview-card { width: 100%; }
164
+ & .video-preview { width: 100%; aspect-ratio: 16 / 9; margin-bottom: 16px; overflow: hidden; border-radius: 6px; background: #05070a; }
165
+ & .video-preview iframe,
166
+ & .video-preview video { display: block; width: 100%; height: 100%; border: 0; object-fit: contain; }
167
+ & .description-preview { color: var(--pt-muted); overflow-wrap: anywhere; }
168
+ & .preview-action { display: flex; justify-content: flex-end; margin-top: 16px; }
169
+ & .details-card { width: 100%; }
170
+ & dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 16px; margin: 0; }
171
+ & dt { color: var(--pt-muted); }
172
+ & dd { margin: 0; overflow-wrap: anywhere; }
173
+ & .refresh-button { margin-top: 14px; }
174
+ & .metadata { max-width: 100%; overflow-x: auto; margin: 14px 0 0; padding: 12px; border-radius: 6px; background: var(--pt-code); font-size: 12px; }
175
+ & .translation-list { border-top: 1px solid var(--pt-border); }
176
+ & .translation-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--pt-border); }
177
+ & .translation-name { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 8px; }
178
+ & .translation-form { display: flex; align-items: flex-end; gap: 8px; margin-top: 16px; }
179
+ & .translation-form .field { flex: 1 1 auto; margin: 0; }
180
+ & .translation-complete { margin: 14px 0 0; }
181
+ & .actions,
182
+ & .form-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
183
+ & .actions { margin: 0; }
184
+ & .details-actions { margin-top: 16px; }
185
+ & .actions form { display: inline; }
186
+ & .form-actions input[type="submit"] { width: auto; }
187
+ & .post-form { width: 100%; }
188
+ & .form-card { padding: 0; }
189
+ & .form-section { padding: 12px 0 4px; }
190
+ & .form-section + .form-section { margin-top: 4px; }
191
+ & .form-section h2 { margin-bottom: 12px; }
192
+ & .field { margin-bottom: 16px; }
193
+ & .field label { display: block; margin-bottom: 5px; font-weight: 600; }
194
+ & .field small { display: block; margin-top: 5px; color: var(--pt-muted); }
195
+ & .section-intro { margin: -4px 0 12px; color: var(--pt-muted); }
196
+ & .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
197
+ & .action-picker,
198
+ & .video-source-picker { display: grid; gap: 10px; min-width: 0; margin: 0 0 18px; padding: 0; border: 0; }
199
+ & .action-picker { grid-template-columns: repeat(3, 1fr); }
200
+ & .video-source-picker { grid-template-columns: repeat(2, 1fr); }
201
+ & .action-choice { display: flex; align-items: flex-start; gap: 10px; min-width: 0; margin: 0; padding: 12px; border: 1px solid var(--pt-border); border-radius: 8px; cursor: pointer; }
202
+ & .action-choice:hover { border-color: var(--pt-muted); }
203
+ & .action-choice.selected { border-color: var(--pt-accent); background: color-mix(in srgb, var(--pt-accent) 7%, var(--pt-surface)); box-shadow: 0 0 0 1px var(--pt-accent); }
204
+ & .action-choice input { width: 18px; min-width: 18px; min-height: 18px; margin: 2px 0 0; }
205
+ & .action-choice span { min-width: 0; }
206
+ & .action-choice strong,
207
+ & .action-choice small { display: block; }
208
+ & .action-choice small { margin-top: 3px; color: var(--pt-muted); font-weight: 400; line-height: 1.35; }
209
+ & .preview-action { align-items: center; gap: 12px; }
210
+ & .preview-back { margin-right: auto; color: var(--pt-muted); }
211
+ & .checkbox { display: flex !important; align-items: center; gap: 8px; margin-top: 8px; font-weight: 400 !important; }
212
+ & .checkbox input { width: 18px; min-height: 18px; }
213
+ & .form-actions { padding-top: 12px; }
214
+ & .form-video-preview { margin: 2px 0 16px; }
215
+ & .form-video-preview .video-preview { margin-bottom: 6px; }
216
+ & .form-video-preview > small { color: var(--pt-muted); }
217
+ & .rich-editor { overflow: hidden; border: 1px solid var(--pt-border); border-radius: 8px; background: var(--pt-surface); }
218
+ & .rich-toolbar { display: flex; gap: 4px; padding: 7px; border-bottom: 1px solid var(--pt-border); background: var(--pt-bg); }
219
+ & .rich-toolbar button { width: 36px; height: 34px; min-height: 34px; padding: 0; border-color: transparent; background: transparent; font-weight: 600; }
220
+ & .rich-content { min-height: 160px; padding: 12px; outline: none; overflow-wrap: anywhere; }
221
+ & .rich-content:focus { box-shadow: inset 0 0 0 2px var(--pt-accent); }
196
222
  }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ProductTours
4
- VERSION = '0.1.2'
4
+ VERSION = '0.2.0'
5
5
  end
data/lib/product_tours.rb CHANGED
@@ -24,6 +24,14 @@ module ProductTours
24
24
  !!config.enabled.call(request)
25
25
  end
26
26
 
27
+ # The class ProductTours::DashboardController inherits from. Resolved on
28
+ # every call rather than memoized, so a host that reassigns
29
+ # base_controller_class in a reloadable initializer is not pinned to a
30
+ # stale, unloaded constant.
31
+ def base_controller
32
+ config.base_controller_class.to_s.constantize
33
+ end
34
+
27
35
  def admin?(request)
28
36
  !!config.authorize_admin.call(request)
29
37
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: product_tours
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Shmarov
@@ -40,7 +40,9 @@ files:
40
40
  - MIT-LICENSE
41
41
  - README.md
42
42
  - Rakefile
43
+ - app/controllers/concerns/product_tours/request_context.rb
43
44
  - app/controllers/product_tours/application_controller.rb
45
+ - app/controllers/product_tours/dashboard_controller.rb
44
46
  - app/controllers/product_tours/media_controller.rb
45
47
  - app/controllers/product_tours/posts_controller.rb
46
48
  - app/controllers/product_tours/tours_controller.rb
@@ -56,6 +58,7 @@ files:
56
58
  - app/views/product_tours/posts/index.html.erb
57
59
  - app/views/product_tours/posts/new.html.erb
58
60
  - app/views/product_tours/posts/show.html.erb
61
+ - app/views/product_tours/shared/_dashboard.html.erb
59
62
  - config/locales/product_tours.ar.yml
60
63
  - config/locales/product_tours.bg.yml
61
64
  - config/locales/product_tours.bn.yml
@@ -90,6 +93,7 @@ files:
90
93
  - lib/generators/product_tours/install/install_generator.rb
91
94
  - lib/generators/product_tours/install/templates/create_product_tours_posts.rb.tt
92
95
  - lib/generators/product_tours/install/templates/initializer.rb
96
+ - lib/generators/product_tours/migration_helpers.rb
93
97
  - lib/product_tours.rb
94
98
  - lib/product_tours/configuration.rb
95
99
  - lib/product_tours/content_security_policy.rb