helios-press 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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +122 -0
  4. data/Rakefile +3 -0
  5. data/app/assets/stylesheets/helios/press/application.css +15 -0
  6. data/app/assets/stylesheets/helios/press/blocks.css +231 -0
  7. data/app/controllers/helios/press/admin/base_controller.rb +9 -0
  8. data/app/controllers/helios/press/admin/block_images_controller.rb +75 -0
  9. data/app/controllers/helios/press/admin/blocks_controller.rb +103 -0
  10. data/app/controllers/helios/press/admin/posts_controller.rb +72 -0
  11. data/app/controllers/helios/press/api/base_controller.rb +25 -0
  12. data/app/controllers/helios/press/api/posts_controller.rb +54 -0
  13. data/app/controllers/helios/press/application_controller.rb +6 -0
  14. data/app/controllers/helios/press/posts_controller.rb +9 -0
  15. data/app/helpers/helios/press/application_helper.rb +39 -0
  16. data/app/javascript/helios/press/controllers/blocks_controller.js +276 -0
  17. data/app/javascript/helios/press/controllers/image_block_controller.js +178 -0
  18. data/app/javascript/helios/press/controllers/text_block_controller.js +63 -0
  19. data/app/javascript/helios/press/index.js +6 -0
  20. data/app/jobs/helios/press/application_job.rb +6 -0
  21. data/app/mailers/helios/press/application_mailer.rb +8 -0
  22. data/app/models/helios/press/application_record.rb +7 -0
  23. data/app/models/helios/press/block.rb +34 -0
  24. data/app/models/helios/press/block_image.rb +16 -0
  25. data/app/models/helios/press/post.rb +39 -0
  26. data/app/views/helios/press/admin/blocks/_block.html.erb +25 -0
  27. data/app/views/helios/press/admin/blocks/_image_block.html.erb +47 -0
  28. data/app/views/helios/press/admin/blocks/_image_item.html.erb +38 -0
  29. data/app/views/helios/press/admin/blocks/_text_block.html.erb +39 -0
  30. data/app/views/helios/press/admin/blocks/_video_block.html.erb +43 -0
  31. data/app/views/helios/press/admin/posts/_blocks_container.html.erb +27 -0
  32. data/app/views/helios/press/admin/posts/_form.html.erb +48 -0
  33. data/app/views/helios/press/admin/posts/edit.html.erb +26 -0
  34. data/app/views/helios/press/admin/posts/index.html.erb +31 -0
  35. data/app/views/helios/press/admin/posts/new.html.erb +12 -0
  36. data/app/views/helios/press/posts/blocks/_image_container.html.erb +18 -0
  37. data/app/views/helios/press/posts/blocks/_text.html.erb +3 -0
  38. data/app/views/helios/press/posts/blocks/_video_container.html.erb +12 -0
  39. data/app/views/helios/press/posts/show.html.erb +45 -0
  40. data/app/views/layouts/helios/press/admin.html.erb +1 -0
  41. data/app/views/layouts/helios/press/application.html.erb +17 -0
  42. data/config/routes.rb +21 -0
  43. data/db/migrate/20250510000001_create_helios_press_posts.rb +18 -0
  44. data/db/migrate/20250510000002_create_helios_press_blocks.rb +14 -0
  45. data/db/migrate/20250510000003_create_helios_press_block_images.rb +13 -0
  46. data/lib/helios/press/configuration.rb +17 -0
  47. data/lib/helios/press/engine.rb +11 -0
  48. data/lib/helios/press/version.rb +5 -0
  49. data/lib/helios/press.rb +23 -0
  50. data/lib/tasks/helios/press_tasks.rake +4 -0
  51. metadata +148 -0
@@ -0,0 +1,54 @@
1
+ module Helios
2
+ module Press
3
+ module Api
4
+ class PostsController < Helios::Press::Api::BaseController
5
+ # POST /api/v1/posts
6
+ # Upsert a post by external_id. Payload shape:
7
+ # {
8
+ # "external_id": "helios-blog-post-draft-4821",
9
+ # "title": "...",
10
+ # "slug": "...",
11
+ # "keywords": "...",
12
+ # "description": "...",
13
+ # "body_html": "...",
14
+ # "published": true
15
+ # }
16
+ def create
17
+ external_id = params[:external_id].to_s
18
+ return render json: { error: "external_id is required" }, status: :bad_request if external_id.blank?
19
+
20
+ post = Post.find_or_initialize_by(external_id: external_id)
21
+
22
+ post.assign_attributes(
23
+ name: params[:title],
24
+ slug: params[:slug].presence,
25
+ keywords: params[:keywords],
26
+ description: params[:description],
27
+ published: ActiveModel::Type::Boolean.new.cast(params[:published])
28
+ )
29
+
30
+ # If body_html is provided, create/update a single text block with the content
31
+ if params[:body_html].present?
32
+ if post.persisted?
33
+ # Update or create the first text block
34
+ text_block = post.blocks.find_by(block_type: "text") || post.blocks.build(block_type: "text", position: 0)
35
+ text_block.content = params[:body_html]
36
+ else
37
+ post.blocks.build(block_type: "text", position: 0, content: params[:body_html])
38
+ end
39
+ end
40
+
41
+ post.save!
42
+
43
+ render json: {
44
+ ok: true,
45
+ id: post.id,
46
+ slug: post.slug
47
+ }, status: :ok
48
+ rescue ActiveRecord::RecordInvalid => e
49
+ render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,6 @@
1
+ module Helios
2
+ module Press
3
+ class ApplicationController < ActionController::Base
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,9 @@
1
+ module Helios
2
+ module Press
3
+ class PostsController < ::ApplicationController
4
+ def show
5
+ @post = Post.published.find_by!(slug: params[:slug])
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,39 @@
1
+ module Helios
2
+ module Press
3
+ module ApplicationHelper
4
+ # Processes ActionText content to add target="_blank" to external links
5
+ def helios_press_process_rich_text_links(rich_text_content)
6
+ return "" if rich_text_content.blank?
7
+
8
+ html = rich_text_content.to_s
9
+ doc = Nokogiri::HTML.fragment(html)
10
+
11
+ doc.css("a").each do |link|
12
+ href = link["href"]
13
+ next if href.blank?
14
+
15
+ if helios_press_external_link?(href)
16
+ link["target"] = "_blank"
17
+ link["rel"] = "noopener noreferrer"
18
+ end
19
+ end
20
+
21
+ doc.to_html.html_safe
22
+ end
23
+
24
+ private
25
+
26
+ def helios_press_external_link?(href)
27
+ return false if href.start_with?("/", "#")
28
+
29
+ begin
30
+ uri = URI.parse(href)
31
+ return false if uri.host.nil?
32
+ uri.host != request.host
33
+ rescue URI::InvalidURIError
34
+ false
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,276 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import Sortable from "sortablejs"
3
+ import { DirectUpload } from "@rails/activestorage"
4
+
5
+ export default class extends Controller {
6
+ static targets = ["blocksContainer", "block", "placeholder"]
7
+ static values = {
8
+ postId: Number,
9
+ baseUrl: String
10
+ }
11
+
12
+ connect() {
13
+ this.initializeSortable()
14
+ }
15
+
16
+ disconnect() {
17
+ if (this.sortable) {
18
+ this.sortable.destroy()
19
+ }
20
+ }
21
+
22
+ blocksContainerTargetConnected() {
23
+ this.initializeSortable()
24
+ }
25
+
26
+ initializeSortable() {
27
+ if (this.sortable) {
28
+ this.sortable.destroy()
29
+ }
30
+
31
+ if (this.hasBlocksContainerTarget) {
32
+ this.sortable = Sortable.create(this.blocksContainerTarget, {
33
+ handle: '.drag-handle',
34
+ animation: 150,
35
+ ghostClass: 'block-ghost',
36
+ dragClass: 'block-dragging',
37
+ onEnd: this.handleReorder.bind(this)
38
+ })
39
+ }
40
+ }
41
+
42
+ handleReorder(event) {
43
+ const blockIds = this.blockTargets.map(block => block.dataset.blockId)
44
+
45
+ fetch(`${this.baseUrlValue}/reorder`, {
46
+ method: 'PATCH',
47
+ headers: {
48
+ 'Content-Type': 'application/json',
49
+ 'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
50
+ },
51
+ body: JSON.stringify({ block_ids: blockIds })
52
+ })
53
+ }
54
+
55
+ addBlock(event) {
56
+ const placeholder = event.currentTarget
57
+ const position = parseInt(placeholder.dataset.position)
58
+ this.createBlock('text', position)
59
+ }
60
+
61
+ handlePlaceholderDragOver(event) {
62
+ event.preventDefault()
63
+ event.currentTarget.classList.add('drag-over')
64
+ }
65
+
66
+ handlePlaceholderDragLeave(event) {
67
+ event.currentTarget.classList.remove('drag-over')
68
+ }
69
+
70
+ handlePlaceholderDrop(event) {
71
+ event.preventDefault()
72
+ event.currentTarget.classList.remove('drag-over')
73
+
74
+ const files = Array.from(event.dataTransfer.files)
75
+ const placeholder = event.currentTarget
76
+ const position = parseInt(placeholder.dataset.position)
77
+
78
+ if (files.length > 0) {
79
+ const firstFile = files[0]
80
+
81
+ if (firstFile.type.startsWith('video/')) {
82
+ this.createVideoBlock(position, firstFile)
83
+ } else if (firstFile.type.startsWith('image/')) {
84
+ this.createImageBlock(position, files.filter(f => f.type.startsWith('image/')))
85
+ }
86
+ }
87
+ }
88
+
89
+ createBlock(blockType, position) {
90
+ fetch(this.baseUrlValue, {
91
+ method: 'POST',
92
+ headers: {
93
+ 'Content-Type': 'application/json',
94
+ 'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content,
95
+ 'Accept': 'text/vnd.turbo-stream.html'
96
+ },
97
+ body: JSON.stringify({
98
+ block: {
99
+ block_type: blockType,
100
+ position: position
101
+ }
102
+ })
103
+ })
104
+ .then(response => response.text())
105
+ .then(html => {
106
+ Turbo.renderStreamMessage(html)
107
+ })
108
+ .catch(error => {
109
+ console.error('Error adding block:', error)
110
+ })
111
+ }
112
+
113
+ createImageBlock(position, files) {
114
+ const formData = new FormData()
115
+ formData.append('block[block_type]', 'image_container')
116
+ formData.append('block[position]', position)
117
+
118
+ files.forEach((file, index) => {
119
+ formData.append(`images[${index}][file]`, file)
120
+ })
121
+
122
+ fetch(this.baseUrlValue, {
123
+ method: 'POST',
124
+ headers: {
125
+ 'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content,
126
+ 'Accept': 'text/vnd.turbo-stream.html'
127
+ },
128
+ body: formData
129
+ })
130
+ .then(response => response.text())
131
+ .then(html => {
132
+ Turbo.renderStreamMessage(html)
133
+ })
134
+ .catch(error => {
135
+ console.error('Error creating image block:', error)
136
+ })
137
+ }
138
+
139
+ deleteBlock(event) {
140
+ event.preventDefault()
141
+ const blockElement = event.target.closest('[data-helios-press-blocks-target="block"]')
142
+ const blockId = blockElement.dataset.blockId
143
+
144
+ if (confirm('Are you sure you want to delete this block?')) {
145
+ fetch(`${this.baseUrlValue}/${blockId}`, {
146
+ method: 'DELETE',
147
+ headers: {
148
+ 'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
149
+ }
150
+ })
151
+ .then(() => {
152
+ blockElement.remove()
153
+ })
154
+ }
155
+ }
156
+
157
+ createVideoBlock(position, file) {
158
+ const placeholderId = `video-upload-${Date.now()}`
159
+ const placeholderHTML = `
160
+ <div id="${placeholderId}" class="content-block card mb-3" data-helios-press-blocks-target="block" data-block-id="uploading">
161
+ <div class="card-body">
162
+ <div class="d-flex align-items-center">
163
+ <div class="spinner-border spinner-border-sm me-2" role="status">
164
+ <span class="visually-hidden">Uploading...</span>
165
+ </div>
166
+ <div class="flex-grow-1">
167
+ <div class="mb-1"><strong>Uploading video...</strong></div>
168
+ <div class="progress" style="height: 4px;">
169
+ <div class="progress-bar" role="progressbar" style="width: 0%" id="${placeholderId}-progress"></div>
170
+ </div>
171
+ </div>
172
+ </div>
173
+ </div>
174
+ </div>
175
+ `
176
+
177
+ const placeholderElement = this.placeholderTargets.find(p => parseInt(p.dataset.position) === position + 1)
178
+ if (placeholderElement) {
179
+ placeholderElement.insertAdjacentHTML('beforebegin', placeholderHTML)
180
+ } else {
181
+ this.blocksContainerTarget.insertAdjacentHTML('beforeend', placeholderHTML)
182
+ }
183
+
184
+ const progressBar = document.getElementById(`${placeholderId}-progress`)
185
+ const uploadUrl = document.querySelector('meta[name="direct-upload-url"]')?.content || '/rails/active_storage/direct_uploads'
186
+
187
+ const upload = new DirectUpload(file, uploadUrl, {
188
+ directUploadWillStoreFileWithXHR: (xhr) => {
189
+ xhr.upload.addEventListener("progress", (event) => {
190
+ const progress = (event.loaded / event.total) * 100
191
+ progressBar.style.width = `${progress}%`
192
+ })
193
+ }
194
+ })
195
+
196
+ upload.create((error, blob) => {
197
+ const uploadElement = document.getElementById(placeholderId)
198
+ if (error) {
199
+ console.error('Upload error:', error)
200
+ uploadElement.innerHTML = `
201
+ <div class="card-body">
202
+ <div class="alert alert-danger mb-0">Upload failed: ${error}</div>
203
+ </div>
204
+ `
205
+ } else {
206
+ uploadElement.innerHTML = `
207
+ <div class="card-body">
208
+ <div class="mb-3">
209
+ <video controls style="max-width: 100%; max-height: 300px;">
210
+ <source src="${URL.createObjectURL(file)}" type="${file.type}">
211
+ </video>
212
+ </div>
213
+ <div class="mb-3">
214
+ <label class="form-label small mb-1">Video Name</label>
215
+ <input type="text" class="form-control form-control-sm" placeholder="Enter video name..." id="${placeholderId}-name">
216
+ </div>
217
+ <div class="d-flex justify-content-between align-items-center">
218
+ <small class="text-muted">Video uploaded - click Save to add to post</small>
219
+ <div class="btn-group">
220
+ <button class="btn btn-sm btn-primary" data-action="click->helios-press-blocks#saveVideoBlock" data-signed-id="${blob.signed_id}" data-position="${position}" data-placeholder-id="${placeholderId}">
221
+ Save
222
+ </button>
223
+ <button class="btn btn-sm btn-secondary" data-action="click->helios-press-blocks#cancelVideoUpload" data-placeholder-id="${placeholderId}">
224
+ Cancel
225
+ </button>
226
+ </div>
227
+ </div>
228
+ </div>
229
+ `
230
+ }
231
+ })
232
+ }
233
+
234
+ saveVideoBlock(event) {
235
+ const button = event.currentTarget
236
+ const signedId = button.dataset.signedId
237
+ const position = parseInt(button.dataset.position)
238
+ const placeholderId = button.dataset.placeholderId
239
+
240
+ const nameInput = document.getElementById(`${placeholderId}-name`)
241
+ const videoName = nameInput?.value || ''
242
+
243
+ button.disabled = true
244
+ button.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Saving...'
245
+
246
+ const formData = new FormData()
247
+ formData.append('block[block_type]', 'video_container')
248
+ formData.append('block[position]', position)
249
+ formData.append('video_signed_id', signedId)
250
+ formData.append('video_name', videoName)
251
+
252
+ fetch(this.baseUrlValue, {
253
+ method: 'POST',
254
+ headers: {
255
+ 'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content,
256
+ 'Accept': 'text/vnd.turbo-stream.html'
257
+ },
258
+ body: formData
259
+ })
260
+ .then(response => response.text())
261
+ .then(html => {
262
+ document.getElementById(placeholderId)?.remove()
263
+ Turbo.renderStreamMessage(html)
264
+ })
265
+ .catch(error => {
266
+ console.error('Error creating video block:', error)
267
+ button.disabled = false
268
+ button.innerHTML = 'Save'
269
+ })
270
+ }
271
+
272
+ cancelVideoUpload(event) {
273
+ const placeholderId = event.currentTarget.dataset.placeholderId
274
+ document.getElementById(placeholderId)?.remove()
275
+ }
276
+ }
@@ -0,0 +1,178 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import Sortable from "sortablejs"
3
+ import { DirectUpload } from "@rails/activestorage"
4
+
5
+ export default class extends Controller {
6
+ static targets = ["dropzone", "imagesContainer", "image", "grid", "columnsSelect"]
7
+ static values = {
8
+ id: Number,
9
+ postId: Number,
10
+ baseUrl: String,
11
+ blockUrl: String,
12
+ uploadUrl: String
13
+ }
14
+
15
+ connect() {
16
+ this.initializeSortable()
17
+ }
18
+
19
+ initializeSortable() {
20
+ if (this.hasGridTarget) {
21
+ this.sortable = Sortable.create(this.gridTarget, {
22
+ animation: 150,
23
+ ghostClass: 'image-ghost',
24
+ filter: '.image-dropzone',
25
+ onEnd: this.handleImageReorder.bind(this)
26
+ })
27
+ }
28
+ }
29
+
30
+ handleDragOver(event) {
31
+ event.preventDefault()
32
+ this.dropzoneTarget.classList.add('drag-over')
33
+ }
34
+
35
+ handleDragLeave(event) {
36
+ this.dropzoneTarget.classList.remove('drag-over')
37
+ }
38
+
39
+ handleDrop(event) {
40
+ event.preventDefault()
41
+ this.dropzoneTarget.classList.remove('drag-over')
42
+
43
+ const files = Array.from(event.dataTransfer.files).filter(file =>
44
+ file.type.startsWith('image/')
45
+ )
46
+
47
+ if (files.length > 0) {
48
+ this.uploadImages(files)
49
+ }
50
+ }
51
+
52
+ uploadImages(files) {
53
+ files.forEach(file => {
54
+ const upload = new DirectUpload(
55
+ file,
56
+ this.uploadUrlValue || '/rails/active_storage/direct_uploads'
57
+ )
58
+
59
+ upload.create((error, blob) => {
60
+ if (error) {
61
+ console.error('Upload error:', error)
62
+ } else {
63
+ this.createBlockImage(blob.signed_id)
64
+ }
65
+ })
66
+ })
67
+ }
68
+
69
+ createBlockImage(signedId) {
70
+ const formData = new FormData()
71
+ formData.append('block_image[file]', signedId)
72
+
73
+ fetch(this.baseUrlValue, {
74
+ method: 'POST',
75
+ headers: {
76
+ 'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content,
77
+ 'Accept': 'text/vnd.turbo-stream.html'
78
+ },
79
+ body: formData
80
+ })
81
+ .then(response => response.text())
82
+ .then(html => {
83
+ Turbo.renderStreamMessage(html)
84
+ })
85
+ .catch(error => {
86
+ console.error('Error creating image:', error)
87
+ })
88
+ }
89
+
90
+ handleImageReorder(event) {
91
+ const imageIds = this.imageTargets.map(img => img.dataset.imageId)
92
+
93
+ fetch(`${this.baseUrlValue}/reorder`, {
94
+ method: 'PATCH',
95
+ headers: {
96
+ 'Content-Type': 'application/json',
97
+ 'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
98
+ },
99
+ body: JSON.stringify({ image_ids: imageIds })
100
+ })
101
+ }
102
+
103
+ editCaption(event) {
104
+ const viewCaption = event.currentTarget
105
+ const editCaption = viewCaption.nextElementSibling
106
+
107
+ viewCaption.classList.add('d-none')
108
+ editCaption.classList.remove('d-none')
109
+ editCaption.querySelector('input').focus()
110
+ }
111
+
112
+ saveCaption(event) {
113
+ const input = event.currentTarget
114
+ const imageId = input.dataset.imageId
115
+ const caption = input.value
116
+
117
+ fetch(`${this.baseUrlValue}/${imageId}`, {
118
+ method: 'PATCH',
119
+ headers: {
120
+ 'Content-Type': 'application/json',
121
+ 'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
122
+ },
123
+ body: JSON.stringify({ block_image: { caption: caption } })
124
+ })
125
+ .then(() => {
126
+ const editCaption = input.closest('.caption-edit')
127
+ const viewCaption = editCaption.previousElementSibling
128
+
129
+ viewCaption.textContent = caption || 'Click to add caption...'
130
+ viewCaption.classList.remove('d-none')
131
+ editCaption.classList.add('d-none')
132
+ })
133
+ }
134
+
135
+ cancelCaption(event) {
136
+ const editCaption = event.currentTarget.closest('.caption-edit')
137
+ const viewCaption = editCaption.previousElementSibling
138
+
139
+ viewCaption.classList.remove('d-none')
140
+ editCaption.classList.add('d-none')
141
+ }
142
+
143
+ deleteImage(event) {
144
+ event.preventDefault()
145
+ const imageElement = event.currentTarget.closest('[data-helios-press-image-block-target="image"]')
146
+ const imageId = imageElement.dataset.imageId
147
+
148
+ if (confirm('Delete this image?')) {
149
+ fetch(`${this.baseUrlValue}/${imageId}`, {
150
+ method: 'DELETE',
151
+ headers: {
152
+ 'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
153
+ }
154
+ })
155
+ .then(() => {
156
+ imageElement.remove()
157
+ })
158
+ }
159
+ }
160
+
161
+ updateColumns(event) {
162
+ const columns = parseInt(event.target.value)
163
+
164
+ if (this.hasGridTarget) {
165
+ this.gridTarget.style.setProperty('--grid-columns', columns)
166
+ this.gridTarget.dataset.columns = columns
167
+ }
168
+
169
+ fetch(this.blockUrlValue, {
170
+ method: 'PATCH',
171
+ headers: {
172
+ 'Content-Type': 'application/json',
173
+ 'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
174
+ },
175
+ body: JSON.stringify({ block: { columns: columns } })
176
+ })
177
+ }
178
+ }
@@ -0,0 +1,63 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["viewMode", "editMode", "form"]
5
+ static values = {
6
+ id: Number,
7
+ postId: Number,
8
+ baseUrl: String,
9
+ mode: String
10
+ }
11
+
12
+ connect() {
13
+ this.showCurrentMode()
14
+ }
15
+
16
+ enterEditMode(event) {
17
+ event.preventDefault()
18
+ this.modeValue = "edit"
19
+ this.showCurrentMode()
20
+ }
21
+
22
+ cancelEdit(event) {
23
+ event.preventDefault()
24
+ this.modeValue = "view"
25
+ this.showCurrentMode()
26
+ }
27
+
28
+ save(event) {
29
+ event.preventDefault()
30
+ const formData = new FormData(this.formTarget)
31
+
32
+ fetch(this.baseUrlValue, {
33
+ method: 'PATCH',
34
+ headers: {
35
+ 'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content,
36
+ 'Accept': 'text/vnd.turbo-stream.html'
37
+ },
38
+ body: formData
39
+ })
40
+ .then(response => response.text())
41
+ .then(html => {
42
+ Turbo.renderStreamMessage(html)
43
+ this.modeValue = "view"
44
+ })
45
+ .catch(error => {
46
+ console.error('Error saving block:', error)
47
+ })
48
+ }
49
+
50
+ showCurrentMode() {
51
+ if (this.modeValue === "edit") {
52
+ this.viewModeTarget.classList.add('d-none')
53
+ this.editModeTarget.classList.remove('d-none')
54
+ } else {
55
+ this.viewModeTarget.classList.remove('d-none')
56
+ this.editModeTarget.classList.add('d-none')
57
+ }
58
+ }
59
+
60
+ modeValueChanged() {
61
+ this.showCurrentMode()
62
+ }
63
+ }
@@ -0,0 +1,6 @@
1
+ // Helios Press - Stimulus Controllers
2
+ // Import these in your host app's application.js or controllers/index.js
3
+
4
+ export { default as HeliosPressBlocksController } from "./controllers/blocks_controller"
5
+ export { default as HeliosPressTextBlockController } from "./controllers/text_block_controller"
6
+ export { default as HeliosPressImageBlockController } from "./controllers/image_block_controller"
@@ -0,0 +1,6 @@
1
+ module Helios
2
+ module Press
3
+ class ApplicationJob < ActiveJob::Base
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,8 @@
1
+ module Helios
2
+ module Press
3
+ class ApplicationMailer < ActionMailer::Base
4
+ default from: "from@example.com"
5
+ layout "mailer"
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,7 @@
1
+ module Helios
2
+ module Press
3
+ class ApplicationRecord < ActiveRecord::Base
4
+ self.abstract_class = true
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,34 @@
1
+ module Helios
2
+ module Press
3
+ class Block < ActiveRecord::Base
4
+ self.table_name = "helios_press_blocks"
5
+
6
+ belongs_to :post, class_name: "Helios::Press::Post", touch: true
7
+
8
+ has_rich_text :content
9
+ has_many :block_images, -> { order(position: :asc) },
10
+ class_name: "Helios::Press::BlockImage",
11
+ foreign_key: :block_id,
12
+ dependent: :destroy
13
+
14
+ BLOCK_TYPES = %w[text image_container video_container].freeze
15
+
16
+ validates :block_type, presence: true, inclusion: { in: BLOCK_TYPES }
17
+ validates :position, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
18
+
19
+ acts_as_list scope: :post
20
+
21
+ def text?
22
+ block_type == "text"
23
+ end
24
+
25
+ def image_container?
26
+ block_type == "image_container"
27
+ end
28
+
29
+ def video_container?
30
+ block_type == "video_container"
31
+ end
32
+ end
33
+ end
34
+ end