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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +122 -0
- data/Rakefile +3 -0
- data/app/assets/stylesheets/helios/press/application.css +15 -0
- data/app/assets/stylesheets/helios/press/blocks.css +231 -0
- data/app/controllers/helios/press/admin/base_controller.rb +9 -0
- data/app/controllers/helios/press/admin/block_images_controller.rb +75 -0
- data/app/controllers/helios/press/admin/blocks_controller.rb +103 -0
- data/app/controllers/helios/press/admin/posts_controller.rb +72 -0
- data/app/controllers/helios/press/api/base_controller.rb +25 -0
- data/app/controllers/helios/press/api/posts_controller.rb +54 -0
- data/app/controllers/helios/press/application_controller.rb +6 -0
- data/app/controllers/helios/press/posts_controller.rb +9 -0
- data/app/helpers/helios/press/application_helper.rb +39 -0
- data/app/javascript/helios/press/controllers/blocks_controller.js +276 -0
- data/app/javascript/helios/press/controllers/image_block_controller.js +178 -0
- data/app/javascript/helios/press/controllers/text_block_controller.js +63 -0
- data/app/javascript/helios/press/index.js +6 -0
- data/app/jobs/helios/press/application_job.rb +6 -0
- data/app/mailers/helios/press/application_mailer.rb +8 -0
- data/app/models/helios/press/application_record.rb +7 -0
- data/app/models/helios/press/block.rb +34 -0
- data/app/models/helios/press/block_image.rb +16 -0
- data/app/models/helios/press/post.rb +39 -0
- data/app/views/helios/press/admin/blocks/_block.html.erb +25 -0
- data/app/views/helios/press/admin/blocks/_image_block.html.erb +47 -0
- data/app/views/helios/press/admin/blocks/_image_item.html.erb +38 -0
- data/app/views/helios/press/admin/blocks/_text_block.html.erb +39 -0
- data/app/views/helios/press/admin/blocks/_video_block.html.erb +43 -0
- data/app/views/helios/press/admin/posts/_blocks_container.html.erb +27 -0
- data/app/views/helios/press/admin/posts/_form.html.erb +48 -0
- data/app/views/helios/press/admin/posts/edit.html.erb +26 -0
- data/app/views/helios/press/admin/posts/index.html.erb +31 -0
- data/app/views/helios/press/admin/posts/new.html.erb +12 -0
- data/app/views/helios/press/posts/blocks/_image_container.html.erb +18 -0
- data/app/views/helios/press/posts/blocks/_text.html.erb +3 -0
- data/app/views/helios/press/posts/blocks/_video_container.html.erb +12 -0
- data/app/views/helios/press/posts/show.html.erb +45 -0
- data/app/views/layouts/helios/press/admin.html.erb +1 -0
- data/app/views/layouts/helios/press/application.html.erb +17 -0
- data/config/routes.rb +21 -0
- data/db/migrate/20250510000001_create_helios_press_posts.rb +18 -0
- data/db/migrate/20250510000002_create_helios_press_blocks.rb +14 -0
- data/db/migrate/20250510000003_create_helios_press_block_images.rb +13 -0
- data/lib/helios/press/configuration.rb +17 -0
- data/lib/helios/press/engine.rb +11 -0
- data/lib/helios/press/version.rb +5 -0
- data/lib/helios/press.rb +23 -0
- data/lib/tasks/helios/press_tasks.rake +4 -0
- 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,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,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
|