railspress-engine 0.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +20 -0
  3. data/README.md +195 -25
  4. data/app/assets/javascripts/railspress/admin.js +39 -0
  5. data/app/assets/javascripts/railspress/markdown_mode.js +343 -0
  6. data/app/assets/stylesheets/application.css +0 -0
  7. data/app/assets/stylesheets/railspress/admin/badges.css +70 -0
  8. data/app/assets/stylesheets/railspress/admin/base.css +25 -0
  9. data/app/assets/stylesheets/railspress/admin/buttons.css +140 -0
  10. data/app/assets/stylesheets/railspress/admin/cards.css +52 -0
  11. data/app/assets/stylesheets/railspress/admin/components/exports.css +55 -0
  12. data/app/assets/stylesheets/railspress/admin/components/focal_point.css +801 -0
  13. data/app/assets/stylesheets/railspress/admin/components/imports.css +144 -0
  14. data/app/assets/stylesheets/railspress/admin/components/lexxy.css +156 -0
  15. data/app/assets/stylesheets/railspress/admin/filters.css +73 -0
  16. data/app/assets/stylesheets/railspress/admin/flash.css +26 -0
  17. data/app/assets/stylesheets/railspress/admin/forms.css +459 -0
  18. data/app/assets/stylesheets/railspress/admin/layout.css +256 -0
  19. data/app/assets/stylesheets/railspress/admin/lists.css +24 -0
  20. data/app/assets/stylesheets/railspress/admin/page.css +111 -0
  21. data/app/assets/stylesheets/railspress/admin/responsive.css +174 -0
  22. data/app/assets/stylesheets/railspress/admin/stats.css +43 -0
  23. data/app/assets/stylesheets/railspress/admin/tables.css +163 -0
  24. data/app/assets/stylesheets/railspress/admin/utilities.css +202 -0
  25. data/app/assets/stylesheets/railspress/admin/variables.css +58 -0
  26. data/app/assets/stylesheets/railspress/application.css +44 -13
  27. data/app/controllers/railspress/admin/base_controller.rb +6 -3
  28. data/app/controllers/railspress/admin/categories_controller.rb +1 -1
  29. data/app/controllers/railspress/admin/cms_transfers_controller.rb +49 -0
  30. data/app/controllers/railspress/admin/content_element_versions_controller.rb +12 -0
  31. data/app/controllers/railspress/admin/content_elements_controller.rb +143 -0
  32. data/app/controllers/railspress/admin/content_groups_controller.rb +69 -0
  33. data/app/controllers/railspress/admin/dashboard_controller.rb +6 -0
  34. data/app/controllers/railspress/admin/entities_controller.rb +157 -0
  35. data/app/controllers/railspress/admin/exports_controller.rb +55 -0
  36. data/app/controllers/railspress/admin/focal_points_controller.rb +100 -0
  37. data/app/controllers/railspress/admin/imports_controller.rb +63 -0
  38. data/app/controllers/railspress/admin/posts_controller.rb +58 -4
  39. data/app/controllers/railspress/admin/prototypes_controller.rb +30 -0
  40. data/app/controllers/railspress/admin/tags_controller.rb +1 -1
  41. data/app/controllers/railspress/application_controller.rb +1 -0
  42. data/app/helpers/railspress/admin_helper.rb +733 -0
  43. data/app/helpers/railspress/application_helper.rb +23 -0
  44. data/app/helpers/railspress/cms_helper.rb +319 -0
  45. data/app/javascript/railspress/controllers/cms_inline_editor_controller.js +147 -0
  46. data/app/javascript/railspress/controllers/content_element_form_controller.js +15 -0
  47. data/app/javascript/railspress/controllers/crop_controller.js +224 -0
  48. data/app/javascript/railspress/controllers/dropzone_controller.js +261 -0
  49. data/app/javascript/railspress/controllers/focal_point_controller.js +124 -0
  50. data/app/javascript/railspress/controllers/image_section_controller.js +94 -0
  51. data/app/javascript/railspress/controllers/index.js +37 -0
  52. data/app/javascript/railspress/index.js +62 -0
  53. data/app/jobs/railspress/export_posts_job.rb +16 -0
  54. data/app/jobs/railspress/import_posts_job.rb +44 -0
  55. data/app/models/concerns/railspress/has_focal_point.rb +242 -0
  56. data/app/models/concerns/railspress/soft_deletable.rb +23 -0
  57. data/app/models/concerns/railspress/taggable.rb +23 -0
  58. data/app/models/railspress/content_element.rb +103 -0
  59. data/app/models/railspress/content_element_version.rb +32 -0
  60. data/app/models/railspress/content_group.rb +39 -0
  61. data/app/models/railspress/export.rb +67 -0
  62. data/app/models/railspress/focal_point.rb +70 -0
  63. data/app/models/railspress/import.rb +65 -0
  64. data/app/models/railspress/post.rb +102 -15
  65. data/app/models/railspress/post_export_processor.rb +162 -0
  66. data/app/models/railspress/post_import_processor.rb +382 -0
  67. data/app/models/railspress/tag.rb +10 -3
  68. data/app/models/railspress/tagging.rb +11 -0
  69. data/app/services/railspress/content_export_service.rb +122 -0
  70. data/app/services/railspress/content_import_service.rb +228 -0
  71. data/app/views/action_text/attachables/_remote_image.html.erb +8 -0
  72. data/app/views/active_storage/blobs/_blob.html.erb +1 -1
  73. data/app/views/layouts/railspress/admin.html.erb +3 -1
  74. data/app/views/railspress/admin/categories/index.html.erb +11 -15
  75. data/app/views/railspress/admin/cms_transfers/show.html.erb +167 -0
  76. data/app/views/railspress/admin/content_element_versions/show.html.erb +42 -0
  77. data/app/views/railspress/admin/content_elements/_form.html.erb +71 -0
  78. data/app/views/railspress/admin/content_elements/_inline_form.html.erb +32 -0
  79. data/app/views/railspress/admin/content_elements/_inline_form_frame.html.erb +6 -0
  80. data/app/views/railspress/admin/content_elements/edit.html.erb +6 -0
  81. data/app/views/railspress/admin/content_elements/index.html.erb +74 -0
  82. data/app/views/railspress/admin/content_elements/new.html.erb +6 -0
  83. data/app/views/railspress/admin/content_elements/show.html.erb +124 -0
  84. data/app/views/railspress/admin/content_groups/_form.html.erb +9 -0
  85. data/app/views/railspress/admin/content_groups/edit.html.erb +6 -0
  86. data/app/views/railspress/admin/content_groups/index.html.erb +42 -0
  87. data/app/views/railspress/admin/content_groups/new.html.erb +6 -0
  88. data/app/views/railspress/admin/content_groups/show.html.erb +92 -0
  89. data/app/views/railspress/admin/dashboard/index.html.erb +36 -1
  90. data/app/views/railspress/admin/entities/_form.html.erb +53 -0
  91. data/app/views/railspress/admin/entities/edit.html.erb +4 -0
  92. data/app/views/railspress/admin/entities/index.html.erb +74 -0
  93. data/app/views/railspress/admin/entities/new.html.erb +4 -0
  94. data/app/views/railspress/admin/entities/show.html.erb +117 -0
  95. data/app/views/railspress/admin/exports/show.html.erb +62 -0
  96. data/app/views/railspress/admin/imports/_instructions.html.erb +56 -0
  97. data/app/views/railspress/admin/imports/show.html.erb +137 -0
  98. data/app/views/railspress/admin/posts/_form.html.erb +102 -28
  99. data/app/views/railspress/admin/posts/_post_row.html.erb +40 -0
  100. data/app/views/railspress/admin/posts/index.html.erb +47 -36
  101. data/app/views/railspress/admin/posts/show.html.erb +55 -19
  102. data/app/views/railspress/admin/prototypes/image_section.html.erb +42 -0
  103. data/app/views/railspress/admin/shared/_dropzone.html.erb +84 -0
  104. data/app/views/railspress/admin/shared/_focal_point_editor.html.erb +102 -0
  105. data/app/views/railspress/admin/shared/_image_section.html.erb +159 -0
  106. data/app/views/railspress/admin/shared/_image_section_compact.html.erb +90 -0
  107. data/app/views/railspress/admin/shared/_image_section_editor.html.erb +171 -0
  108. data/app/views/railspress/admin/shared/_image_section_v2.html.erb +205 -0
  109. data/app/views/railspress/admin/shared/_sidebar.html.erb +73 -5
  110. data/app/views/railspress/admin/tags/index.html.erb +12 -16
  111. data/config/brakeman.ignore +18 -0
  112. data/config/importmap.rb +23 -0
  113. data/config/routes.rb +62 -1
  114. data/db/migrate/20241218000004_create_railspress_post_tags.rb +1 -1
  115. data/db/migrate/20241218000005_create_railspress_imports.rb +21 -0
  116. data/db/migrate/20241218000006_create_railspress_exports.rb +20 -0
  117. data/db/migrate/20241218000007_create_railspress_taggings.rb +20 -0
  118. data/db/migrate/20241218000008_drop_railspress_post_tags.rb +14 -0
  119. data/db/migrate/20241218000010_add_reading_time_to_railspress_posts.rb +5 -0
  120. data/db/migrate/20250105000002_create_railspress_focal_points.rb +20 -0
  121. data/db/migrate/20260206000001_create_railspress_content_groups.rb +18 -0
  122. data/db/migrate/20260206000002_create_railspress_content_elements.rb +21 -0
  123. data/db/migrate/20260206000003_create_railspress_content_element_versions.rb +20 -0
  124. data/db/migrate/20260207000001_add_unique_index_to_content_elements.rb +11 -0
  125. data/db/migrate/20260211112812_add_image_hint_to_railspress_content_elements.rb +7 -0
  126. data/db/migrate/20260211154040_add_required_to_railspress_content_elements.rb +5 -0
  127. data/lib/generators/railspress/entity/entity_generator.rb +89 -0
  128. data/lib/generators/railspress/entity/templates/migration.rb.tt +13 -0
  129. data/lib/generators/railspress/entity/templates/model.rb.tt +21 -0
  130. data/lib/generators/railspress/install/install_generator.rb +51 -40
  131. data/lib/generators/railspress/install/templates/initializer.rb +29 -0
  132. data/lib/railspress/engine.rb +38 -0
  133. data/lib/railspress/entity.rb +239 -0
  134. data/lib/railspress/version.rb +1 -1
  135. data/lib/railspress.rb +198 -8
  136. data/lib/tasks/railspress_tasks.rake +49 -4
  137. metadata +215 -21
  138. data/MIT-LICENSE +0 -20
  139. data/app/assets/stylesheets/railspress/admin.css +0 -1207
  140. data/app/models/railspress/post_tag.rb +0 -8
@@ -0,0 +1,224 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * Crop Controller
5
+ *
6
+ * Wraps Cropper.js for image cropping. Lazy-loads Cropper.js from CDN
7
+ * when first needed to avoid bundling the library.
8
+ *
9
+ * Usage:
10
+ * <dialog data-controller="railspress--crop"
11
+ * data-railspress--crop-aspect-ratio-value="1.778"
12
+ * data-railspress--crop-src-value="/path/to/image.jpg">
13
+ *
14
+ * <img data-railspress--crop-target="image">
15
+ *
16
+ * <button data-action="railspress--crop#apply">Apply Crop</button>
17
+ * <button data-action="railspress--crop#cancel">Cancel</button>
18
+ * <button data-action="railspress--crop#reset">Reset</button>
19
+ * </dialog>
20
+ *
21
+ * Events:
22
+ * - railspress--crop:apply - Dispatched with { region: { x, y, width, height } }
23
+ * - railspress--crop:cancel - Dispatched when crop is cancelled
24
+ */
25
+ export default class extends Controller {
26
+ static targets = ["image", "preview"]
27
+
28
+ static values = {
29
+ src: String,
30
+ aspectRatio: { type: Number, default: 0 }, // 0 = free aspect ratio
31
+ viewMode: { type: Number, default: 1 },
32
+ minWidth: { type: Number, default: 100 },
33
+ minHeight: { type: Number, default: 100 },
34
+ cropperUrl: {
35
+ type: String,
36
+ default: "https://cdn.jsdelivr.net/npm/cropperjs@1.6.2/dist/cropper.min.js"
37
+ },
38
+ cropperCssUrl: {
39
+ type: String,
40
+ default: "https://cdn.jsdelivr.net/npm/cropperjs@1.6.2/dist/cropper.min.css"
41
+ }
42
+ }
43
+
44
+ cropper = null
45
+ cropperLoaded = false
46
+
47
+ connect() {
48
+ // If this is a dialog element, set up show/close handlers
49
+ if (this.element.tagName === "DIALOG") {
50
+ this.element.addEventListener("close", this.handleClose.bind(this))
51
+ }
52
+ }
53
+
54
+ disconnect() {
55
+ this.destroyCropper()
56
+ }
57
+
58
+ // Opens the crop dialog and initializes cropper
59
+ async open(imageSrc = null) {
60
+ if (imageSrc) {
61
+ this.srcValue = imageSrc
62
+ }
63
+
64
+ // Show dialog if it's a dialog element
65
+ if (this.element.tagName === "DIALOG" && !this.element.open) {
66
+ this.element.showModal()
67
+ }
68
+
69
+ await this.initializeCropper()
70
+ }
71
+
72
+ async initializeCropper() {
73
+ // Load Cropper.js if not already loaded
74
+ if (!window.Cropper) {
75
+ await this.loadCropper()
76
+ }
77
+
78
+ // Set image source
79
+ if (this.hasImageTarget && this.srcValue) {
80
+ this.imageTarget.src = this.srcValue
81
+
82
+ // Wait for image to load
83
+ await new Promise((resolve, reject) => {
84
+ this.imageTarget.onload = resolve
85
+ this.imageTarget.onerror = reject
86
+ })
87
+
88
+ // Initialize Cropper
89
+ this.destroyCropper() // Clean up any existing instance
90
+ this.cropper = new window.Cropper(this.imageTarget, {
91
+ aspectRatio: this.aspectRatioValue || NaN, // NaN = free aspect ratio
92
+ viewMode: this.viewModeValue,
93
+ minCropBoxWidth: this.minWidthValue,
94
+ minCropBoxHeight: this.minHeightValue,
95
+ autoCropArea: 1,
96
+ responsive: true,
97
+ restore: true,
98
+ guides: true,
99
+ center: true,
100
+ highlight: true,
101
+ background: true,
102
+ movable: true,
103
+ rotatable: false,
104
+ scalable: false,
105
+ zoomable: true,
106
+ zoomOnTouch: true,
107
+ zoomOnWheel: true,
108
+ cropBoxMovable: true,
109
+ cropBoxResizable: true,
110
+ toggleDragModeOnDblclick: false
111
+ })
112
+ }
113
+ }
114
+
115
+ async loadCropper() {
116
+ if (this.cropperLoaded) return
117
+
118
+ // Load CSS
119
+ const link = document.createElement("link")
120
+ link.rel = "stylesheet"
121
+ link.href = this.cropperCssUrlValue
122
+ document.head.appendChild(link)
123
+
124
+ // Load JS
125
+ await new Promise((resolve, reject) => {
126
+ const script = document.createElement("script")
127
+ script.src = this.cropperUrlValue
128
+ script.onload = resolve
129
+ script.onerror = reject
130
+ document.head.appendChild(script)
131
+ })
132
+
133
+ this.cropperLoaded = true
134
+ }
135
+
136
+ apply(event) {
137
+ event?.preventDefault()
138
+
139
+ if (!this.cropper) return
140
+
141
+ const cropData = this.cropper.getData(true) // true = rounded values
142
+ const imageData = this.cropper.getImageData()
143
+
144
+ // Calculate normalized region (0-1 values)
145
+ const region = {
146
+ x: cropData.x / imageData.naturalWidth,
147
+ y: cropData.y / imageData.naturalHeight,
148
+ width: cropData.width / imageData.naturalWidth,
149
+ height: cropData.height / imageData.naturalHeight
150
+ }
151
+
152
+ this.dispatch("apply", { detail: { region, cropData, imageData } })
153
+ this.close()
154
+ }
155
+
156
+ cancel(event) {
157
+ event?.preventDefault()
158
+ this.dispatch("cancel")
159
+ this.close()
160
+ }
161
+
162
+ reset(event) {
163
+ event?.preventDefault()
164
+ if (this.cropper) {
165
+ this.cropper.reset()
166
+ }
167
+ }
168
+
169
+ close() {
170
+ if (this.element.tagName === "DIALOG" && this.element.open) {
171
+ this.element.close()
172
+ }
173
+ this.destroyCropper()
174
+ }
175
+
176
+ handleClose() {
177
+ this.destroyCropper()
178
+ }
179
+
180
+ destroyCropper() {
181
+ if (this.cropper) {
182
+ this.cropper.destroy()
183
+ this.cropper = null
184
+ }
185
+ }
186
+
187
+ // Set aspect ratio dynamically
188
+ setAspectRatio(ratio) {
189
+ this.aspectRatioValue = ratio
190
+ if (this.cropper) {
191
+ this.cropper.setAspectRatio(ratio || NaN)
192
+ }
193
+ }
194
+
195
+ // Zoom controls
196
+ zoomIn(event) {
197
+ event?.preventDefault()
198
+ if (this.cropper) {
199
+ this.cropper.zoom(0.1)
200
+ }
201
+ }
202
+
203
+ zoomOut(event) {
204
+ event?.preventDefault()
205
+ if (this.cropper) {
206
+ this.cropper.zoom(-0.1)
207
+ }
208
+ }
209
+
210
+ // Rotation (if enabled)
211
+ rotateLeft(event) {
212
+ event?.preventDefault()
213
+ if (this.cropper) {
214
+ this.cropper.rotate(-90)
215
+ }
216
+ }
217
+
218
+ rotateRight(event) {
219
+ event?.preventDefault()
220
+ if (this.cropper) {
221
+ this.cropper.rotate(90)
222
+ }
223
+ }
224
+ }
@@ -0,0 +1,261 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { DirectUpload } from "@rails/activestorage"
3
+
4
+ /**
5
+ * Dropzone Controller
6
+ *
7
+ * Handles drag-and-drop file uploads using ActiveStorage DirectUpload.
8
+ * Provides visual feedback during upload and integrates with forms.
9
+ *
10
+ * Usage:
11
+ * <div data-controller="railspress--dropzone"
12
+ * data-railspress--dropzone-url-value="/rails/active_storage/direct_uploads"
13
+ * data-railspress--dropzone-accept-value="image/*"
14
+ * data-action="drop->railspress--dropzone#drop
15
+ * dragover->railspress--dropzone#dragover
16
+ * dragleave->railspress--dropzone#dragleave">
17
+ *
18
+ * <input type="file" data-railspress--dropzone-target="input" class="sr-only">
19
+ * <input type="hidden" data-railspress--dropzone-target="signedId" name="post[header_image]">
20
+ *
21
+ * <div data-railspress--dropzone-target="dropArea">
22
+ * <span data-railspress--dropzone-target="prompt">Drag image here or click to browse</span>
23
+ * </div>
24
+ *
25
+ * <div data-railspress--dropzone-target="preview" hidden>
26
+ * <img data-railspress--dropzone-target="previewImage">
27
+ * <button data-action="railspress--dropzone#remove">Remove</button>
28
+ * </div>
29
+ *
30
+ * <div data-railspress--dropzone-target="progress" hidden>
31
+ * <div data-railspress--dropzone-target="progressBar"></div>
32
+ * <span data-railspress--dropzone-target="progressText">0%</span>
33
+ * </div>
34
+ *
35
+ * <div data-railspress--dropzone-target="error" hidden></div>
36
+ * </div>
37
+ */
38
+ export default class extends Controller {
39
+ static targets = [
40
+ "input",
41
+ "signedId",
42
+ "dropArea",
43
+ "prompt",
44
+ "preview",
45
+ "previewImage",
46
+ "progress",
47
+ "progressBar",
48
+ "progressText",
49
+ "error"
50
+ ]
51
+
52
+ static values = {
53
+ url: { type: String, default: "/rails/active_storage/direct_uploads" },
54
+ accept: { type: String, default: "image/*" },
55
+ maxSize: { type: Number, default: 10 * 1024 * 1024 } // 10MB default
56
+ }
57
+
58
+ // Using hardcoded class names for simplicity in engine context
59
+ // Host apps can override via CSS or fork the controller
60
+
61
+ connect() {
62
+ this.element.addEventListener("click", this.openFilePicker.bind(this))
63
+ }
64
+
65
+ disconnect() {
66
+ this.element.removeEventListener("click", this.openFilePicker.bind(this))
67
+ }
68
+
69
+ openFilePicker(event) {
70
+ // Don't open if clicking on remove button or preview
71
+ if (event.target.closest("[data-action*='remove']")) return
72
+ if (event.target.closest("button")) return
73
+
74
+ this.inputTarget.click()
75
+ }
76
+
77
+ dragover(event) {
78
+ event.preventDefault()
79
+ this.element.classList.add("rp-dropzone--dragging")
80
+ }
81
+
82
+ dragleave(event) {
83
+ event.preventDefault()
84
+ this.element.classList.remove("rp-dropzone--dragging")
85
+ }
86
+
87
+ drop(event) {
88
+ event.preventDefault()
89
+ this.element.classList.remove("rp-dropzone--dragging")
90
+
91
+ const files = event.dataTransfer?.files
92
+ if (files?.length > 0) {
93
+ this.handleFile(files[0])
94
+ }
95
+ }
96
+
97
+ // Called when file input changes
98
+ change(event) {
99
+ const file = event.target.files?.[0]
100
+ if (file) {
101
+ this.handleFile(file)
102
+ }
103
+ }
104
+
105
+ handleFile(file) {
106
+ // Validate file type
107
+ if (!this.isValidType(file)) {
108
+ this.showError(`Invalid file type. Please upload ${this.acceptValue}`)
109
+ return
110
+ }
111
+
112
+ // Validate file size
113
+ if (file.size > this.maxSizeValue) {
114
+ const maxMB = Math.round(this.maxSizeValue / 1024 / 1024)
115
+ this.showError(`File too large. Maximum size is ${maxMB}MB`)
116
+ return
117
+ }
118
+
119
+ this.hideError()
120
+ this.showPreview(file)
121
+ this.uploadFile(file)
122
+ }
123
+
124
+ isValidType(file) {
125
+ if (this.acceptValue === "*" || this.acceptValue === "*/*") return true
126
+
127
+ const accepts = this.acceptValue.split(",").map(s => s.trim())
128
+ return accepts.some(accept => {
129
+ if (accept.startsWith(".")) {
130
+ return file.name.toLowerCase().endsWith(accept.toLowerCase())
131
+ }
132
+ if (accept.endsWith("/*")) {
133
+ const baseType = accept.replace("/*", "")
134
+ return file.type.startsWith(baseType)
135
+ }
136
+ return file.type === accept
137
+ })
138
+ }
139
+
140
+ showPreview(file) {
141
+ if (!this.hasPreviewTarget) return
142
+
143
+ const reader = new FileReader()
144
+ reader.onload = (e) => {
145
+ if (this.hasPreviewImageTarget) {
146
+ this.previewImageTarget.src = e.target.result
147
+ }
148
+ this.previewTarget.hidden = false
149
+ if (this.hasDropAreaTarget) {
150
+ this.dropAreaTarget.hidden = true
151
+ }
152
+ }
153
+ reader.readAsDataURL(file)
154
+ }
155
+
156
+ uploadFile(file) {
157
+ this.showProgress()
158
+ this.element.classList.add("rp-dropzone--uploading")
159
+
160
+ const upload = new DirectUpload(file, this.urlValue, this)
161
+
162
+ upload.create((error, blob) => {
163
+ this.hideProgress()
164
+ this.element.classList.remove("rp-dropzone--uploading")
165
+
166
+ if (error) {
167
+ this.showError(`Upload failed: ${error}`)
168
+ this.element.classList.add("rp-dropzone--error")
169
+ } else {
170
+ this.element.classList.add("rp-dropzone--complete")
171
+ if (this.hasSignedIdTarget) {
172
+ this.signedIdTarget.value = blob.signed_id
173
+ }
174
+ this.dispatch("upload", { detail: { blob, signedId: blob.signed_id } })
175
+ }
176
+ })
177
+ }
178
+
179
+ // DirectUpload delegate methods
180
+ directUploadWillStoreFileWithXHR(request) {
181
+ request.upload.addEventListener("progress", event => this.updateProgress(event))
182
+ }
183
+
184
+ updateProgress(event) {
185
+ if (!event.lengthComputable) return
186
+
187
+ const percent = Math.round((event.loaded / event.total) * 100)
188
+
189
+ if (this.hasProgressBarTarget) {
190
+ this.progressBarTarget.style.width = `${percent}%`
191
+ }
192
+ if (this.hasProgressTextTarget) {
193
+ this.progressTextTarget.textContent = `${percent}%`
194
+ }
195
+ }
196
+
197
+ showProgress() {
198
+ if (this.hasProgressTarget) {
199
+ this.progressTarget.hidden = false
200
+ if (this.hasProgressBarTarget) {
201
+ this.progressBarTarget.style.width = "0%"
202
+ }
203
+ if (this.hasProgressTextTarget) {
204
+ this.progressTextTarget.textContent = "0%"
205
+ }
206
+ }
207
+ }
208
+
209
+ hideProgress() {
210
+ if (this.hasProgressTarget) {
211
+ this.progressTarget.hidden = true
212
+ }
213
+ }
214
+
215
+ showError(message) {
216
+ if (this.hasErrorTarget) {
217
+ this.errorTarget.textContent = message
218
+ this.errorTarget.hidden = false
219
+ }
220
+ this.element.classList.add("rp-dropzone--error")
221
+ }
222
+
223
+ hideError() {
224
+ if (this.hasErrorTarget) {
225
+ this.errorTarget.hidden = true
226
+ }
227
+ this.element.classList.remove("rp-dropzone--error")
228
+ }
229
+
230
+ remove(event) {
231
+ event.preventDefault()
232
+ event.stopPropagation()
233
+
234
+ // Clear the signed ID
235
+ if (this.hasSignedIdTarget) {
236
+ this.signedIdTarget.value = ""
237
+ }
238
+
239
+ // Clear file input
240
+ if (this.hasInputTarget) {
241
+ this.inputTarget.value = ""
242
+ }
243
+
244
+ // Hide preview, show drop area
245
+ if (this.hasPreviewTarget) {
246
+ this.previewTarget.hidden = true
247
+ if (this.hasPreviewImageTarget) {
248
+ this.previewImageTarget.src = ""
249
+ }
250
+ }
251
+ if (this.hasDropAreaTarget) {
252
+ this.dropAreaTarget.hidden = false
253
+ }
254
+
255
+ // Reset classes
256
+ this.element.classList.remove("rp-dropzone--complete", "rp-dropzone--error")
257
+
258
+ this.hideError()
259
+ this.dispatch("remove")
260
+ }
261
+ }
@@ -0,0 +1,124 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * Focal Point Picker Controller
5
+ *
6
+ * Allows users to click on an image to set the focal point (the most
7
+ * important part of the image) for smart cropping in different contexts.
8
+ *
9
+ * Usage:
10
+ * <div data-controller="railspress--focal-point"
11
+ * data-railspress--focal-point-x-value="0.5"
12
+ * data-railspress--focal-point-y-value="0.5">
13
+ *
14
+ * <div data-action="click->railspress--focal-point#pick">
15
+ * <img data-railspress--focal-point-target="image">
16
+ * <div data-railspress--focal-point-target="crosshair"></div>
17
+ * </div>
18
+ *
19
+ * <input type="hidden" data-railspress--focal-point-target="xInput">
20
+ * <input type="hidden" data-railspress--focal-point-target="yInput">
21
+ *
22
+ * <img data-railspress--focal-point-target="preview">
23
+ * </div>
24
+ */
25
+ export default class extends Controller {
26
+ static targets = ["image", "crosshair", "xInput", "yInput", "coordsDisplay", "preview"]
27
+
28
+ static values = {
29
+ x: { type: Number, default: 0.5 },
30
+ y: { type: Number, default: 0.5 }
31
+ }
32
+
33
+ connect() {
34
+ this.updateUI()
35
+ }
36
+
37
+ pick(event) {
38
+ event.preventDefault()
39
+ const rect = this.imageTarget.getBoundingClientRect()
40
+ this.xValue = this.clamp((event.clientX - rect.left) / rect.width)
41
+ this.yValue = this.clamp((event.clientY - rect.top) / rect.height)
42
+ this.updateUI()
43
+ this.dispatch("change", { detail: { x: this.xValue, y: this.yValue } })
44
+ }
45
+
46
+ touch(event) {
47
+ if (event.touches.length !== 1) return
48
+ event.preventDefault()
49
+ const touch = event.touches[0]
50
+ const rect = this.imageTarget.getBoundingClientRect()
51
+ this.xValue = this.clamp((touch.clientX - rect.left) / rect.width)
52
+ this.yValue = this.clamp((touch.clientY - rect.top) / rect.height)
53
+ this.updateUI()
54
+ this.dispatch("change", { detail: { x: this.xValue, y: this.yValue } })
55
+ }
56
+
57
+ reset() {
58
+ this.xValue = 0.5
59
+ this.yValue = 0.5
60
+ this.updateUI()
61
+ this.dispatch("reset")
62
+ }
63
+
64
+ keydown(event) {
65
+ const step = event.shiftKey ? 0.1 : 0.01
66
+ let handled = true
67
+
68
+ switch (event.key) {
69
+ case "ArrowLeft":
70
+ this.xValue = this.clamp(this.xValue - step)
71
+ break
72
+ case "ArrowRight":
73
+ this.xValue = this.clamp(this.xValue + step)
74
+ break
75
+ case "ArrowUp":
76
+ this.yValue = this.clamp(this.yValue - step)
77
+ break
78
+ case "ArrowDown":
79
+ this.yValue = this.clamp(this.yValue + step)
80
+ break
81
+ default:
82
+ handled = false
83
+ }
84
+
85
+ if (handled) {
86
+ event.preventDefault()
87
+ this.updateUI()
88
+ this.dispatch("change", { detail: { x: this.xValue, y: this.yValue } })
89
+ }
90
+ }
91
+
92
+ updateUI() {
93
+ this.updateCrosshair()
94
+ this.updateInputs()
95
+ this.updatePreviews()
96
+ this.updateCoordsDisplay()
97
+ }
98
+
99
+ updateCrosshair() {
100
+ if (!this.hasCrosshairTarget) return
101
+ this.crosshairTarget.style.left = `${this.xValue * 100}%`
102
+ this.crosshairTarget.style.top = `${this.yValue * 100}%`
103
+ }
104
+
105
+ updateInputs() {
106
+ if (this.hasXInputTarget) this.xInputTarget.value = this.xValue.toFixed(4)
107
+ if (this.hasYInputTarget) this.yInputTarget.value = this.yValue.toFixed(4)
108
+ }
109
+
110
+ updatePreviews() {
111
+ const position = `${this.xValue * 100}% ${this.yValue * 100}%`
112
+ this.previewTargets.forEach(el => el.style.objectPosition = position)
113
+ }
114
+
115
+ updateCoordsDisplay() {
116
+ if (!this.hasCoordsDisplayTarget) return
117
+ this.coordsDisplayTarget.textContent =
118
+ `${Math.round(this.xValue * 100)}%, ${Math.round(this.yValue * 100)}%`
119
+ }
120
+
121
+ clamp(value) {
122
+ return Math.max(0, Math.min(1, value))
123
+ }
124
+ }
@@ -0,0 +1,94 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * Image Section Controller
5
+ *
6
+ * Handles expand/collapse transitions for the image section.
7
+ * Uses CSS animations for smooth transitions without server round-trips.
8
+ *
9
+ * Usage:
10
+ * <div data-controller="rp-image-section"
11
+ * data-rp-image-section-expanded-value="false">
12
+ *
13
+ * <div data-rp-image-section-target="compact"
14
+ * data-action="click->rp-image-section#expand">
15
+ * <!-- compact view -->
16
+ * </div>
17
+ *
18
+ * <div data-rp-image-section-target="editor" hidden>
19
+ * <!-- expanded editor -->
20
+ * <button data-action="rp-image-section#collapse">Done</button>
21
+ * </div>
22
+ * </div>
23
+ */
24
+ export default class extends Controller {
25
+ static targets = ["compact", "editor", "editButton"]
26
+
27
+ static values = {
28
+ expanded: { type: Boolean, default: false }
29
+ }
30
+
31
+ connect() {
32
+ // Sync UI with initial state
33
+ this.updateUI()
34
+ }
35
+
36
+ toggle() {
37
+ this.expandedValue = !this.expandedValue
38
+ this.updateUI()
39
+ }
40
+
41
+ expand() {
42
+ if (this.expandedValue) return
43
+ this.expandedValue = true
44
+ this.updateUI()
45
+ }
46
+
47
+ collapse() {
48
+ if (!this.expandedValue) return
49
+ this.expandedValue = false
50
+ this.updateUI()
51
+ }
52
+
53
+ updateUI() {
54
+ if (this.expandedValue) {
55
+ this.showEditor()
56
+ } else {
57
+ this.showCompact()
58
+ }
59
+ this.updateButtonText()
60
+ }
61
+
62
+ showEditor() {
63
+ if (this.hasCompactTarget) {
64
+ this.compactTarget.hidden = true
65
+ }
66
+ if (this.hasEditorTarget) {
67
+ this.editorTarget.hidden = false
68
+ this.editorTarget.classList.add("rp-image-section-v2__editor--entering")
69
+ // Remove animation class after animation completes
70
+ requestAnimationFrame(() => {
71
+ requestAnimationFrame(() => {
72
+ this.editorTarget.classList.remove("rp-image-section-v2__editor--entering")
73
+ })
74
+ })
75
+ }
76
+ this.element.classList.add("rp-image-section-v2--expanded")
77
+ }
78
+
79
+ showCompact() {
80
+ if (this.hasEditorTarget) {
81
+ this.editorTarget.hidden = true
82
+ }
83
+ if (this.hasCompactTarget) {
84
+ this.compactTarget.hidden = false
85
+ }
86
+ this.element.classList.remove("rp-image-section-v2--expanded")
87
+ }
88
+
89
+ updateButtonText() {
90
+ if (this.hasEditButtonTarget) {
91
+ this.editButtonTarget.textContent = this.expandedValue ? "Collapse" : "Edit"
92
+ }
93
+ }
94
+ }