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 +4 -4
- data/CHANGELOG.md +35 -0
- data/README.md +2 -1
- data/app/controllers/concerns/product_tours/request_context.rb +73 -0
- data/app/controllers/product_tours/application_controller.rb +9 -61
- data/app/controllers/product_tours/dashboard_controller.rb +32 -0
- data/app/controllers/product_tours/posts_controller.rb +1 -3
- data/app/views/layouts/product_tours/application.html.erb +10 -9
- data/app/views/product_tours/posts/edit.html.erb +5 -3
- data/app/views/product_tours/posts/index.html.erb +81 -79
- data/app/views/product_tours/posts/new.html.erb +5 -3
- data/app/views/product_tours/posts/show.html.erb +4 -2
- data/app/views/product_tours/shared/_dashboard.html.erb +16 -0
- data/config/routes.rb +5 -1
- data/lib/generators/product_tours/install/install_generator.rb +2 -6
- data/lib/generators/product_tours/install/templates/create_product_tours_posts.rb.tt +1 -2
- data/lib/generators/product_tours/install/templates/initializer.rb +8 -0
- data/lib/generators/product_tours/migration_helpers.rb +35 -0
- data/lib/product_tours/configuration.rb +21 -1
- data/lib/product_tours/dashboard.css +203 -177
- data/lib/product_tours/version.rb +1 -1
- data/lib/product_tours.rb +8 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fd91edac94c4a143630f7ba27c3892e750fbce4ea6f4c59490389ceae46a3e63
|
|
4
|
+
data.tar.gz: ab9eac5f0d14a4548bd49b89ccb7ad195031db2967123be5637953f318a439b3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
| `
|
|
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
|
-
|
|
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
|
-
|
|
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 <
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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="
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
4
|
-
<
|
|
5
|
-
|
|
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
|
-
|
|
4
|
-
<
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
</nav>
|
|
19
|
+
</nav>
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
<div class="empty"><%= t("product_tours.dashboard.empty", default: "Nothing here yet.") %></div>
|
|
59
|
-
<% end %>
|
|
60
|
-
</div>
|
|
61
|
+
</div>
|
|
61
62
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
4
|
-
<
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
.
|
|
53
|
-
.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
.
|
|
58
|
-
|
|
59
|
-
.
|
|
60
|
-
.
|
|
61
|
-
|
|
62
|
-
.
|
|
63
|
-
.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
.
|
|
185
|
-
|
|
186
|
-
.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
.
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
.
|
|
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
|
}
|
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.
|
|
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
|