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.
- checksums.yaml +4 -4
- data/LICENSE +20 -0
- data/README.md +195 -25
- data/app/assets/javascripts/railspress/admin.js +39 -0
- data/app/assets/javascripts/railspress/markdown_mode.js +343 -0
- data/app/assets/stylesheets/application.css +0 -0
- data/app/assets/stylesheets/railspress/admin/badges.css +70 -0
- data/app/assets/stylesheets/railspress/admin/base.css +25 -0
- data/app/assets/stylesheets/railspress/admin/buttons.css +140 -0
- data/app/assets/stylesheets/railspress/admin/cards.css +52 -0
- data/app/assets/stylesheets/railspress/admin/components/exports.css +55 -0
- data/app/assets/stylesheets/railspress/admin/components/focal_point.css +801 -0
- data/app/assets/stylesheets/railspress/admin/components/imports.css +144 -0
- data/app/assets/stylesheets/railspress/admin/components/lexxy.css +156 -0
- data/app/assets/stylesheets/railspress/admin/filters.css +73 -0
- data/app/assets/stylesheets/railspress/admin/flash.css +26 -0
- data/app/assets/stylesheets/railspress/admin/forms.css +459 -0
- data/app/assets/stylesheets/railspress/admin/layout.css +256 -0
- data/app/assets/stylesheets/railspress/admin/lists.css +24 -0
- data/app/assets/stylesheets/railspress/admin/page.css +111 -0
- data/app/assets/stylesheets/railspress/admin/responsive.css +174 -0
- data/app/assets/stylesheets/railspress/admin/stats.css +43 -0
- data/app/assets/stylesheets/railspress/admin/tables.css +163 -0
- data/app/assets/stylesheets/railspress/admin/utilities.css +202 -0
- data/app/assets/stylesheets/railspress/admin/variables.css +58 -0
- data/app/assets/stylesheets/railspress/application.css +44 -13
- data/app/controllers/railspress/admin/base_controller.rb +6 -3
- data/app/controllers/railspress/admin/categories_controller.rb +1 -1
- data/app/controllers/railspress/admin/cms_transfers_controller.rb +49 -0
- data/app/controllers/railspress/admin/content_element_versions_controller.rb +12 -0
- data/app/controllers/railspress/admin/content_elements_controller.rb +143 -0
- data/app/controllers/railspress/admin/content_groups_controller.rb +69 -0
- data/app/controllers/railspress/admin/dashboard_controller.rb +6 -0
- data/app/controllers/railspress/admin/entities_controller.rb +157 -0
- data/app/controllers/railspress/admin/exports_controller.rb +55 -0
- data/app/controllers/railspress/admin/focal_points_controller.rb +100 -0
- data/app/controllers/railspress/admin/imports_controller.rb +63 -0
- data/app/controllers/railspress/admin/posts_controller.rb +58 -4
- data/app/controllers/railspress/admin/prototypes_controller.rb +30 -0
- data/app/controllers/railspress/admin/tags_controller.rb +1 -1
- data/app/controllers/railspress/application_controller.rb +1 -0
- data/app/helpers/railspress/admin_helper.rb +733 -0
- data/app/helpers/railspress/application_helper.rb +23 -0
- data/app/helpers/railspress/cms_helper.rb +319 -0
- data/app/javascript/railspress/controllers/cms_inline_editor_controller.js +147 -0
- data/app/javascript/railspress/controllers/content_element_form_controller.js +15 -0
- data/app/javascript/railspress/controllers/crop_controller.js +224 -0
- data/app/javascript/railspress/controllers/dropzone_controller.js +261 -0
- data/app/javascript/railspress/controllers/focal_point_controller.js +124 -0
- data/app/javascript/railspress/controllers/image_section_controller.js +94 -0
- data/app/javascript/railspress/controllers/index.js +37 -0
- data/app/javascript/railspress/index.js +62 -0
- data/app/jobs/railspress/export_posts_job.rb +16 -0
- data/app/jobs/railspress/import_posts_job.rb +44 -0
- data/app/models/concerns/railspress/has_focal_point.rb +242 -0
- data/app/models/concerns/railspress/soft_deletable.rb +23 -0
- data/app/models/concerns/railspress/taggable.rb +23 -0
- data/app/models/railspress/content_element.rb +103 -0
- data/app/models/railspress/content_element_version.rb +32 -0
- data/app/models/railspress/content_group.rb +39 -0
- data/app/models/railspress/export.rb +67 -0
- data/app/models/railspress/focal_point.rb +70 -0
- data/app/models/railspress/import.rb +65 -0
- data/app/models/railspress/post.rb +102 -15
- data/app/models/railspress/post_export_processor.rb +162 -0
- data/app/models/railspress/post_import_processor.rb +382 -0
- data/app/models/railspress/tag.rb +10 -3
- data/app/models/railspress/tagging.rb +11 -0
- data/app/services/railspress/content_export_service.rb +122 -0
- data/app/services/railspress/content_import_service.rb +228 -0
- data/app/views/action_text/attachables/_remote_image.html.erb +8 -0
- data/app/views/active_storage/blobs/_blob.html.erb +1 -1
- data/app/views/layouts/railspress/admin.html.erb +3 -1
- data/app/views/railspress/admin/categories/index.html.erb +11 -15
- data/app/views/railspress/admin/cms_transfers/show.html.erb +167 -0
- data/app/views/railspress/admin/content_element_versions/show.html.erb +42 -0
- data/app/views/railspress/admin/content_elements/_form.html.erb +71 -0
- data/app/views/railspress/admin/content_elements/_inline_form.html.erb +32 -0
- data/app/views/railspress/admin/content_elements/_inline_form_frame.html.erb +6 -0
- data/app/views/railspress/admin/content_elements/edit.html.erb +6 -0
- data/app/views/railspress/admin/content_elements/index.html.erb +74 -0
- data/app/views/railspress/admin/content_elements/new.html.erb +6 -0
- data/app/views/railspress/admin/content_elements/show.html.erb +124 -0
- data/app/views/railspress/admin/content_groups/_form.html.erb +9 -0
- data/app/views/railspress/admin/content_groups/edit.html.erb +6 -0
- data/app/views/railspress/admin/content_groups/index.html.erb +42 -0
- data/app/views/railspress/admin/content_groups/new.html.erb +6 -0
- data/app/views/railspress/admin/content_groups/show.html.erb +92 -0
- data/app/views/railspress/admin/dashboard/index.html.erb +36 -1
- data/app/views/railspress/admin/entities/_form.html.erb +53 -0
- data/app/views/railspress/admin/entities/edit.html.erb +4 -0
- data/app/views/railspress/admin/entities/index.html.erb +74 -0
- data/app/views/railspress/admin/entities/new.html.erb +4 -0
- data/app/views/railspress/admin/entities/show.html.erb +117 -0
- data/app/views/railspress/admin/exports/show.html.erb +62 -0
- data/app/views/railspress/admin/imports/_instructions.html.erb +56 -0
- data/app/views/railspress/admin/imports/show.html.erb +137 -0
- data/app/views/railspress/admin/posts/_form.html.erb +102 -28
- data/app/views/railspress/admin/posts/_post_row.html.erb +40 -0
- data/app/views/railspress/admin/posts/index.html.erb +47 -36
- data/app/views/railspress/admin/posts/show.html.erb +55 -19
- data/app/views/railspress/admin/prototypes/image_section.html.erb +42 -0
- data/app/views/railspress/admin/shared/_dropzone.html.erb +84 -0
- data/app/views/railspress/admin/shared/_focal_point_editor.html.erb +102 -0
- data/app/views/railspress/admin/shared/_image_section.html.erb +159 -0
- data/app/views/railspress/admin/shared/_image_section_compact.html.erb +90 -0
- data/app/views/railspress/admin/shared/_image_section_editor.html.erb +171 -0
- data/app/views/railspress/admin/shared/_image_section_v2.html.erb +205 -0
- data/app/views/railspress/admin/shared/_sidebar.html.erb +73 -5
- data/app/views/railspress/admin/tags/index.html.erb +12 -16
- data/config/brakeman.ignore +18 -0
- data/config/importmap.rb +23 -0
- data/config/routes.rb +62 -1
- data/db/migrate/20241218000004_create_railspress_post_tags.rb +1 -1
- data/db/migrate/20241218000005_create_railspress_imports.rb +21 -0
- data/db/migrate/20241218000006_create_railspress_exports.rb +20 -0
- data/db/migrate/20241218000007_create_railspress_taggings.rb +20 -0
- data/db/migrate/20241218000008_drop_railspress_post_tags.rb +14 -0
- data/db/migrate/20241218000010_add_reading_time_to_railspress_posts.rb +5 -0
- data/db/migrate/20250105000002_create_railspress_focal_points.rb +20 -0
- data/db/migrate/20260206000001_create_railspress_content_groups.rb +18 -0
- data/db/migrate/20260206000002_create_railspress_content_elements.rb +21 -0
- data/db/migrate/20260206000003_create_railspress_content_element_versions.rb +20 -0
- data/db/migrate/20260207000001_add_unique_index_to_content_elements.rb +11 -0
- data/db/migrate/20260211112812_add_image_hint_to_railspress_content_elements.rb +7 -0
- data/db/migrate/20260211154040_add_required_to_railspress_content_elements.rb +5 -0
- data/lib/generators/railspress/entity/entity_generator.rb +89 -0
- data/lib/generators/railspress/entity/templates/migration.rb.tt +13 -0
- data/lib/generators/railspress/entity/templates/model.rb.tt +21 -0
- data/lib/generators/railspress/install/install_generator.rb +51 -40
- data/lib/generators/railspress/install/templates/initializer.rb +29 -0
- data/lib/railspress/engine.rb +38 -0
- data/lib/railspress/entity.rb +239 -0
- data/lib/railspress/version.rb +1 -1
- data/lib/railspress.rb +198 -8
- data/lib/tasks/railspress_tasks.rake +49 -4
- metadata +215 -21
- data/MIT-LICENSE +0 -20
- data/app/assets/stylesheets/railspress/admin.css +0 -1207
- 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
|
+
}
|