product_tours 0.1.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 +7 -0
- data/CHANGELOG.md +41 -0
- data/MIT-LICENSE +19 -0
- data/README.md +209 -0
- data/Rakefile +11 -0
- data/app/controllers/product_tours/application_controller.rb +68 -0
- data/app/controllers/product_tours/media_controller.rb +14 -0
- data/app/controllers/product_tours/posts_controller.rb +230 -0
- data/app/controllers/product_tours/tours_controller.rb +69 -0
- data/app/controllers/product_tours/widgets_controller.rb +28 -0
- data/app/helpers/product_tours/posts_helper.rb +19 -0
- data/app/helpers/product_tours/widget_helper.rb +14 -0
- data/app/models/product_tours/application_record.rb +7 -0
- data/app/models/product_tours/post.rb +128 -0
- data/app/views/layouts/product_tours/application.html.erb +20 -0
- data/app/views/product_tours/posts/_form.html.erb +177 -0
- data/app/views/product_tours/posts/_post_panel.html.erb +111 -0
- data/app/views/product_tours/posts/edit.html.erb +5 -0
- data/app/views/product_tours/posts/index.html.erb +92 -0
- data/app/views/product_tours/posts/new.html.erb +5 -0
- data/app/views/product_tours/posts/show.html.erb +5 -0
- data/config/locales/product_tours.ar.yml +11 -0
- data/config/locales/product_tours.bg.yml +11 -0
- data/config/locales/product_tours.bn.yml +11 -0
- data/config/locales/product_tours.de.yml +11 -0
- data/config/locales/product_tours.el.yml +11 -0
- data/config/locales/product_tours.en.yml +11 -0
- data/config/locales/product_tours.es.yml +11 -0
- data/config/locales/product_tours.fr.yml +11 -0
- data/config/locales/product_tours.hi.yml +11 -0
- data/config/locales/product_tours.hr.yml +11 -0
- data/config/locales/product_tours.id.yml +11 -0
- data/config/locales/product_tours.it.yml +11 -0
- data/config/locales/product_tours.ja.yml +11 -0
- data/config/locales/product_tours.ko.yml +11 -0
- data/config/locales/product_tours.lb.yml +11 -0
- data/config/locales/product_tours.nl.yml +11 -0
- data/config/locales/product_tours.pl.yml +11 -0
- data/config/locales/product_tours.pt.yml +11 -0
- data/config/locales/product_tours.ro.yml +11 -0
- data/config/locales/product_tours.ru.yml +11 -0
- data/config/locales/product_tours.th.yml +11 -0
- data/config/locales/product_tours.tr.yml +11 -0
- data/config/locales/product_tours.uk.yml +11 -0
- data/config/locales/product_tours.ur.yml +11 -0
- data/config/locales/product_tours.vi.yml +11 -0
- data/config/locales/product_tours.zh-CN.yml +11 -0
- data/config/routes.rb +23 -0
- data/lib/generators/product_tours/install/install_generator.rb +44 -0
- data/lib/generators/product_tours/install/templates/create_product_tours_posts.rb.tt +29 -0
- data/lib/generators/product_tours/install/templates/initializer.rb +21 -0
- data/lib/product_tours/configuration.rb +18 -0
- data/lib/product_tours/content_security_policy.rb +24 -0
- data/lib/product_tours/dashboard.css +196 -0
- data/lib/product_tours/dashboard.js +229 -0
- data/lib/product_tours/engine.rb +28 -0
- data/lib/product_tours/errors.rb +14 -0
- data/lib/product_tours/seeds.rb +147 -0
- data/lib/product_tours/version.rb +5 -0
- data/lib/product_tours/video_metadata.rb +60 -0
- data/lib/product_tours/video_resolver.rb +129 -0
- data/lib/product_tours/widget.js +359 -0
- data/lib/product_tours/widget.rb +59 -0
- data/lib/product_tours.rb +35 -0
- data/lib/tasks/product_tours_tasks.rake +12 -0
- metadata +131 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
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;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@media (prefers-color-scheme: dark) {
|
|
17
|
+
:root {
|
|
18
|
+
--bg: #111418;
|
|
19
|
+
--surface: #1a1f26;
|
|
20
|
+
--text: #e6e8ea;
|
|
21
|
+
--muted: #9aa2ab;
|
|
22
|
+
--border: #2a313a;
|
|
23
|
+
--accent: #3b82f6;
|
|
24
|
+
--code: #232a33;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
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
|
+
}
|
|
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;
|
|
67
|
+
}
|
|
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
|
+
|
|
88
|
+
.pt-index, .pt-index .container { height: 100vh; height: 100dvh; }
|
|
89
|
+
.pt-index { overflow: hidden; }
|
|
90
|
+
.pt-index .container { display: flex; max-width: 1280px; flex-direction: column; padding-bottom: 16px; }
|
|
91
|
+
.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
|
+
.pt-show { min-height: 100vh; overflow: auto; }
|
|
136
|
+
.pt-show .post-panel { width: 100%; }
|
|
137
|
+
.pt-show .detail-scroll { overflow: visible; }
|
|
138
|
+
.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
|
+
|
|
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; }
|
|
196
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/* product_tours dashboard helpers: strict-CSP-safe confirmations and fields. */
|
|
2
|
+
(function () {
|
|
3
|
+
"use strict";
|
|
4
|
+
|
|
5
|
+
document.addEventListener("click", function (event) {
|
|
6
|
+
var control = event.target.closest && event.target.closest("[data-confirm]");
|
|
7
|
+
if (control && !window.confirm(control.getAttribute("data-confirm"))) event.preventDefault();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
document.addEventListener("change", function (event) {
|
|
11
|
+
if (event.target.matches("[data-autosubmit]")) event.target.form.requestSubmit();
|
|
12
|
+
if (event.target.matches("[data-action-target]")) syncActionPicker(event.target.closest("[data-action-picker]") || event.target.form);
|
|
13
|
+
if (event.target.matches("[data-video-source]")) syncVideoSourcePicker(event.target.closest("[data-video-source-picker]"));
|
|
14
|
+
if (event.target.matches("[name='post[locale]']")) refreshPostOptions(event.target.form);
|
|
15
|
+
if (event.target.matches("[data-action-post-select]")) event.target.dataset.selectedKey = event.target.value;
|
|
16
|
+
if (event.target.matches("[data-product-tour-key]")) normalizeKey(event.target, true);
|
|
17
|
+
if (event.target.matches("[data-video-preview-input]")) scheduleVideoPreview(event.target, 0);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
document.addEventListener("input", function (event) {
|
|
21
|
+
if (event.target.matches("[data-product-tour-key]")) {
|
|
22
|
+
normalizeKey(event.target, false);
|
|
23
|
+
var valid = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/.test(event.target.value);
|
|
24
|
+
event.target.setCustomValidity(valid || !event.target.value ? "" : "Use lowercase letters and numbers separated by ., _, or -.");
|
|
25
|
+
}
|
|
26
|
+
if (event.target.matches("[data-video-preview-input]")) scheduleVideoPreview(event.target, 400);
|
|
27
|
+
if (event.target.matches("[data-rich-content]")) syncRichEditor(event.target.closest("[data-rich-editor]"));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
document.addEventListener("focusout", function (event) {
|
|
31
|
+
if (!event.target.matches("[data-product-tour-key]")) return;
|
|
32
|
+
normalizeKey(event.target, true);
|
|
33
|
+
event.target.setCustomValidity("");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
document.addEventListener("click", function (event) {
|
|
37
|
+
var button = event.target.closest && event.target.closest("[data-rich-command]");
|
|
38
|
+
if (!button) return;
|
|
39
|
+
var editor = button.closest("[data-rich-editor]");
|
|
40
|
+
var content = editor && editor.querySelector("[data-rich-content]");
|
|
41
|
+
if (!content) return;
|
|
42
|
+
|
|
43
|
+
event.preventDefault();
|
|
44
|
+
content.focus();
|
|
45
|
+
var command = button.getAttribute("data-rich-command");
|
|
46
|
+
var value = null;
|
|
47
|
+
if (command === "createLink") {
|
|
48
|
+
value = window.prompt("Link URL", "https://");
|
|
49
|
+
if (!value) return;
|
|
50
|
+
}
|
|
51
|
+
document.execCommand(command, false, value);
|
|
52
|
+
syncRichEditor(editor);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
document.addEventListener("submit", function (event) {
|
|
56
|
+
var editors = event.target.querySelectorAll && event.target.querySelectorAll("[data-rich-editor]");
|
|
57
|
+
if (editors) editors.forEach(syncRichEditor);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
function syncRichEditor(editor) {
|
|
61
|
+
if (!editor) return;
|
|
62
|
+
var input = editor.querySelector("[data-rich-input]");
|
|
63
|
+
var content = editor.querySelector("[data-rich-content]");
|
|
64
|
+
if (input && content) input.value = content.innerHTML;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeKey(input, final) {
|
|
68
|
+
var value = input.value.toLowerCase()
|
|
69
|
+
.replace(/[^a-z0-9._-]+/g, "_")
|
|
70
|
+
.replace(/([._-])[._-]+/g, "$1")
|
|
71
|
+
.replace(/^[._-]+/, "");
|
|
72
|
+
if (final) value = value.replace(/[._-]+$/, "");
|
|
73
|
+
input.value = value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function showFirstVideoFrame(video) {
|
|
77
|
+
function seek() {
|
|
78
|
+
if (!Number.isFinite(video.duration) || video.duration <= 0 || video.currentTime > 0) return;
|
|
79
|
+
try { video.currentTime = Math.min(0.1, video.duration / 2); } catch (_error) {}
|
|
80
|
+
}
|
|
81
|
+
video.addEventListener("loadedmetadata", seek, { once: true });
|
|
82
|
+
if (video.readyState >= 1) seek();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function scheduleVideoPreview(input, delay) {
|
|
86
|
+
if (input.disabled) return;
|
|
87
|
+
window.clearTimeout(input._productToursPreviewTimer);
|
|
88
|
+
input._productToursPreviewTimer = window.setTimeout(function () { loadVideoPreview(input); }, delay);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function loadVideoPreview(input) {
|
|
92
|
+
var form = input.closest("form");
|
|
93
|
+
var preview = form && form.querySelector("[data-video-preview]");
|
|
94
|
+
if (!preview) return;
|
|
95
|
+
|
|
96
|
+
var frame = preview.querySelector("[data-video-preview-frame]");
|
|
97
|
+
var status = preview.querySelector("[data-video-preview-status]");
|
|
98
|
+
var value = input.value.trim();
|
|
99
|
+
var requestId = String(Date.now()) + Math.random();
|
|
100
|
+
preview.dataset.requestId = requestId;
|
|
101
|
+
frame.replaceChildren();
|
|
102
|
+
status.textContent = "";
|
|
103
|
+
preview.hidden = !value;
|
|
104
|
+
if (!value) return;
|
|
105
|
+
|
|
106
|
+
status.textContent = preview.dataset.loading;
|
|
107
|
+
var endpoint = new URL(preview.dataset.endpoint, window.location.origin);
|
|
108
|
+
endpoint.searchParams.set("url", value);
|
|
109
|
+
fetch(endpoint.toString(), { credentials: "same-origin", headers: { Accept: "application/json" } })
|
|
110
|
+
.then(function (response) {
|
|
111
|
+
return response.json().catch(function () { return {}; }).then(function (payload) {
|
|
112
|
+
if (!response.ok) throw new Error(payload.error || preview.dataset.error);
|
|
113
|
+
return payload;
|
|
114
|
+
});
|
|
115
|
+
})
|
|
116
|
+
.then(function (video) {
|
|
117
|
+
if (preview.dataset.requestId !== requestId) return;
|
|
118
|
+
renderVideoPreview(frame, video, form);
|
|
119
|
+
status.textContent = "";
|
|
120
|
+
})
|
|
121
|
+
.catch(function (error) {
|
|
122
|
+
if (preview.dataset.requestId !== requestId) return;
|
|
123
|
+
status.textContent = error.message || preview.dataset.error;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function renderVideoPreview(frame, video, form) {
|
|
128
|
+
var player;
|
|
129
|
+
if (video.kind === "iframe") {
|
|
130
|
+
player = document.createElement("iframe");
|
|
131
|
+
player.src = video.url;
|
|
132
|
+
player.title = video.title || "Video preview";
|
|
133
|
+
player.allow = "autoplay; fullscreen; picture-in-picture";
|
|
134
|
+
player.setAttribute("allowfullscreen", "");
|
|
135
|
+
player.referrerPolicy = "strict-origin-when-cross-origin";
|
|
136
|
+
} else {
|
|
137
|
+
player = document.createElement("video");
|
|
138
|
+
player.src = video.url;
|
|
139
|
+
player.controls = true;
|
|
140
|
+
player.playsInline = true;
|
|
141
|
+
player.preload = "metadata";
|
|
142
|
+
if (video.thumbnail_url) player.poster = video.thumbnail_url;
|
|
143
|
+
showFirstVideoFrame(player);
|
|
144
|
+
}
|
|
145
|
+
frame.appendChild(player);
|
|
146
|
+
|
|
147
|
+
var title = form.querySelector("[name='post[title]']");
|
|
148
|
+
if (title && !title.value.trim() && video.title) title.value = video.title;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function syncActionPicker(scope) {
|
|
152
|
+
if (!scope) return;
|
|
153
|
+
var picker = scope.matches && scope.matches("[data-action-picker]") ? scope : scope.querySelector("[data-action-picker]");
|
|
154
|
+
if (!picker) return;
|
|
155
|
+
var selected = picker.querySelector("[data-action-target]:checked");
|
|
156
|
+
var target = selected ? selected.value : "close";
|
|
157
|
+
picker.querySelectorAll(".action-choice").forEach(function (choice) {
|
|
158
|
+
choice.classList.toggle("selected", choice.contains(selected));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
picker.parentElement.querySelectorAll("[data-action-fields]").forEach(function (fields) {
|
|
162
|
+
var active = fields.getAttribute("data-action-fields") === target;
|
|
163
|
+
fields.hidden = !active;
|
|
164
|
+
fields.querySelectorAll("input, select, textarea").forEach(function (input) { input.disabled = !active; });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
var label = picker.parentElement.querySelector("[data-action-label]");
|
|
168
|
+
if (label) label.placeholder = target === "post" ? "Next" : "Done";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function syncVideoSourcePicker(picker) {
|
|
172
|
+
if (!picker) return;
|
|
173
|
+
var selected = picker.querySelector("[data-video-source]:checked");
|
|
174
|
+
var source = selected ? selected.value : "url";
|
|
175
|
+
picker.querySelectorAll(".action-choice").forEach(function (choice) {
|
|
176
|
+
choice.classList.toggle("selected", choice.contains(selected));
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
var section = picker.parentElement;
|
|
180
|
+
section.querySelectorAll("[data-video-source-fields]").forEach(function (fields) {
|
|
181
|
+
var active = fields.getAttribute("data-video-source-fields") === source;
|
|
182
|
+
fields.hidden = !active;
|
|
183
|
+
fields.querySelectorAll("input, select, textarea").forEach(function (input) {
|
|
184
|
+
input.disabled = !active;
|
|
185
|
+
if (input.type === "file") input.required = active && picker.dataset.hasUpload !== "true";
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
var urlInput = section.querySelector("[data-video-preview-input]");
|
|
190
|
+
if (source === "url" && urlInput && urlInput.value.trim()) scheduleVideoPreview(urlInput, 0);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function refreshPostOptions(form) {
|
|
194
|
+
if (!form) return;
|
|
195
|
+
var select = form.querySelector("[data-action-post-select]");
|
|
196
|
+
var data = form.querySelector("[data-action-post-options]");
|
|
197
|
+
var locale = form.querySelector("[name='post[locale]']");
|
|
198
|
+
if (!select || !data || !locale) return;
|
|
199
|
+
|
|
200
|
+
var posts;
|
|
201
|
+
try { posts = JSON.parse(data.textContent); } catch (_error) { return; }
|
|
202
|
+
var selectedKey = select.dataset.selectedKey || select.value;
|
|
203
|
+
var currentPostId = select.dataset.currentPostId;
|
|
204
|
+
var placeholder = select.options.length ? select.options[0].textContent : "Select the next post…";
|
|
205
|
+
select.replaceChildren(new Option(placeholder, ""));
|
|
206
|
+
|
|
207
|
+
posts.forEach(function (post) {
|
|
208
|
+
if (post.locale !== locale.value || String(post.id) === currentPostId) return;
|
|
209
|
+
select.add(new Option(post.label, post.key));
|
|
210
|
+
});
|
|
211
|
+
select.value = selectedKey;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function initializeDashboard() {
|
|
215
|
+
document.querySelectorAll("[data-video-source-picker]").forEach(syncVideoSourcePicker);
|
|
216
|
+
document.querySelectorAll("[data-action-picker]").forEach(function (picker) {
|
|
217
|
+
var form = picker.closest("form");
|
|
218
|
+
refreshPostOptions(form);
|
|
219
|
+
syncActionPicker(form);
|
|
220
|
+
});
|
|
221
|
+
document.querySelectorAll("[data-video-first-frame]").forEach(showFirstVideoFrame);
|
|
222
|
+
document.querySelectorAll("[data-video-preview-input]").forEach(function (input) {
|
|
223
|
+
if (!input.disabled && input.value.trim()) scheduleVideoPreview(input, 0);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
document.addEventListener("turbo:load", initializeDashboard);
|
|
228
|
+
initializeDashboard();
|
|
229
|
+
})();
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails'
|
|
4
|
+
|
|
5
|
+
module ProductTours
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
isolate_namespace ProductTours
|
|
8
|
+
|
|
9
|
+
initializer 'product_tours.helpers' do
|
|
10
|
+
ActiveSupport.on_load(:action_view) do
|
|
11
|
+
include ProductTours::WidgetHelper
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
initializer 'product_tours.routing' do
|
|
16
|
+
ActionDispatch::Routing::Mapper.include(Module.new do
|
|
17
|
+
def mount_product_tours(at: ProductTours.config.mount_path, **options)
|
|
18
|
+
ProductTours.config.mount_path = at
|
|
19
|
+
mount ProductTours::Engine, at:, **options
|
|
20
|
+
end
|
|
21
|
+
end)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
initializer 'product_tours.content_security_policy', after: :load_config_initializers do |app|
|
|
25
|
+
ProductTours::ContentSecurityPolicy.apply!(app.config.content_security_policy)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ProductTours
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class UnresolvedTriggerError < Error
|
|
7
|
+
attr_reader :payload
|
|
8
|
+
|
|
9
|
+
def initialize(payload)
|
|
10
|
+
@payload = payload
|
|
11
|
+
super("Product tour #{payload[:key].inspect} could not be resolved (#{payload[:reason]})")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ProductTours
|
|
4
|
+
module Seeds
|
|
5
|
+
DEMO_LOCALES = %w[en fr bg].freeze
|
|
6
|
+
|
|
7
|
+
POSTS = [
|
|
8
|
+
{
|
|
9
|
+
key: 'demo_walkthrough_finish',
|
|
10
|
+
title: 'You are ready',
|
|
11
|
+
action_label: 'Get started'
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
key: 'demo_direct_video',
|
|
15
|
+
title: 'Direct MP4 tutorial',
|
|
16
|
+
video_url: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
|
|
17
|
+
action_post_key: 'demo_walkthrough_finish'
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
key: 'demo_voomly',
|
|
21
|
+
title: 'Voomly tutorial',
|
|
22
|
+
video_url: 'https://share.voomly.com/v/CxfDyNYKE0SBEBV-zVAUHo8rNwS2eLBr418z81gd6GlkKlLfJ',
|
|
23
|
+
action_post_key: 'demo_direct_video'
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
key: 'demo_tella',
|
|
27
|
+
title: 'Tella tutorial',
|
|
28
|
+
video_url: 'https://www.tella.tv/video/add-your-logo-to-videos-39y0',
|
|
29
|
+
action_post_key: 'demo_voomly'
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
key: 'demo_loom',
|
|
33
|
+
title: 'Loom tutorial',
|
|
34
|
+
video_url: 'https://www.loom.com/share/e5b8c04bca094dd8a5507925ab887002',
|
|
35
|
+
action_post_key: 'demo_tella'
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
key: 'demo_vimeo',
|
|
39
|
+
title: 'Vimeo tutorial',
|
|
40
|
+
video_url: 'https://vimeo.com/76979871',
|
|
41
|
+
action_post_key: 'demo_loom'
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
key: 'demo_youtube',
|
|
45
|
+
title: 'YouTube tutorial',
|
|
46
|
+
video_url: 'https://www.youtube.com/watch?v=M7lc1UVf-VE',
|
|
47
|
+
action_post_key: 'demo_vimeo'
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
key: 'demo_walkthrough_features',
|
|
51
|
+
title: 'Compare every supported video provider',
|
|
52
|
+
action_post_key: 'demo_youtube'
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
key: 'demo_walkthrough_start',
|
|
56
|
+
title: 'Video provider walkthrough',
|
|
57
|
+
action_post_key: 'demo_walkthrough_features'
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
key: 'demo_getting_started',
|
|
61
|
+
title: 'Getting started',
|
|
62
|
+
action_label: 'Open settings',
|
|
63
|
+
action_url: '/settings'
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
key: 'demo_draft',
|
|
67
|
+
title: 'Draft tutorial',
|
|
68
|
+
status: 'draft'
|
|
69
|
+
}
|
|
70
|
+
].freeze
|
|
71
|
+
|
|
72
|
+
BUTTONS = [
|
|
73
|
+
['demo_walkthrough_start', 'Try the multi-step walkthrough'],
|
|
74
|
+
['demo_youtube', 'Open the YouTube tutorial'],
|
|
75
|
+
['demo_vimeo', 'Open the Vimeo tutorial'],
|
|
76
|
+
['demo_loom', 'Open the Loom tutorial'],
|
|
77
|
+
['demo_tella', 'Open the Tella tutorial'],
|
|
78
|
+
['demo_voomly', 'Open the Voomly tutorial'],
|
|
79
|
+
['demo_direct_video', 'Open the direct video tutorial'],
|
|
80
|
+
['demo_getting_started', 'Open the getting started guide'],
|
|
81
|
+
['demo_draft', 'Try an unpublished tutorial'],
|
|
82
|
+
['demo_missing_post', 'Try a missing tutorial']
|
|
83
|
+
].freeze
|
|
84
|
+
|
|
85
|
+
TRANSLATIONS = {
|
|
86
|
+
'fr' => {
|
|
87
|
+
'demo_walkthrough_finish' => { title: 'Vous êtes prêt', action_label: 'Commencer' },
|
|
88
|
+
'demo_walkthrough_features' => { title: 'Comparez tous les fournisseurs vidéo pris en charge' },
|
|
89
|
+
'demo_walkthrough_start' => { title: 'Présentation des fournisseurs vidéo' },
|
|
90
|
+
'demo_youtube' => { title: 'Tutoriel YouTube' },
|
|
91
|
+
'demo_vimeo' => { title: 'Tutoriel Vimeo' },
|
|
92
|
+
'demo_loom' => { title: 'Tutoriel Loom' },
|
|
93
|
+
'demo_tella' => { title: 'Tutoriel Tella' },
|
|
94
|
+
'demo_voomly' => { title: 'Tutoriel Voomly' },
|
|
95
|
+
'demo_direct_video' => { title: 'Tutoriel vidéo MP4' },
|
|
96
|
+
'demo_getting_started' => { title: 'Bien démarrer', action_label: 'Ouvrir les paramètres' },
|
|
97
|
+
'demo_draft' => { title: 'Tutoriel brouillon' }
|
|
98
|
+
},
|
|
99
|
+
'bg' => {
|
|
100
|
+
'demo_walkthrough_finish' => { title: 'Готови сте', action_label: 'Започнете' },
|
|
101
|
+
'demo_walkthrough_features' => { title: 'Сравнете всички поддържани видео доставчици' },
|
|
102
|
+
'demo_walkthrough_start' => { title: 'Обиколка на видео доставчиците' },
|
|
103
|
+
'demo_youtube' => { title: 'Видео урок в YouTube' },
|
|
104
|
+
'demo_vimeo' => { title: 'Видео урок във Vimeo' },
|
|
105
|
+
'demo_loom' => { title: 'Видео урок в Loom' },
|
|
106
|
+
'demo_tella' => { title: 'Видео урок в Tella' },
|
|
107
|
+
'demo_voomly' => { title: 'Видео урок във Voomly' },
|
|
108
|
+
'demo_direct_video' => { title: 'MP4 видео урок' },
|
|
109
|
+
'demo_getting_started' => { title: 'Първи стъпки', action_label: 'Отвори настройките' },
|
|
110
|
+
'demo_draft' => { title: 'Чернова на урок' }
|
|
111
|
+
}
|
|
112
|
+
}.freeze
|
|
113
|
+
|
|
114
|
+
def self.load!(locale: I18n.default_locale, status: 'published')
|
|
115
|
+
POSTS.map do |attributes|
|
|
116
|
+
locale = locale.to_s
|
|
117
|
+
key = attributes.fetch(:key)
|
|
118
|
+
localized = TRANSLATIONS.fetch(locale, {}).fetch(key, {})
|
|
119
|
+
post = ProductTours::Post.find_or_initialize_by(locale: locale, key: key)
|
|
120
|
+
post.assign_attributes(attributes.merge(localized).merge(status: attributes.fetch(:status, status)))
|
|
121
|
+
post.save!
|
|
122
|
+
post
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def self.load_all!(locales: DEMO_LOCALES, status: 'published')
|
|
127
|
+
locales.flat_map { |locale| load!(locale: locale, status: status) }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def self.load_for_install!(environment: defined?(Rails) && Rails.env)
|
|
131
|
+
return [] unless environment&.development?
|
|
132
|
+
|
|
133
|
+
ProductTours::Post.reset_column_information
|
|
134
|
+
load!
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def self.trigger_html
|
|
138
|
+
lines = ['<div class="product-tours-demo">']
|
|
139
|
+
BUTTONS.each do |key, label|
|
|
140
|
+
lines << %( <button type="button" data-product-tour="#{key}">#{label}</button>)
|
|
141
|
+
end
|
|
142
|
+
lines << '</div>'
|
|
143
|
+
lines << '<%= product_tours_tag %>'
|
|
144
|
+
lines.join("\n")
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|